Add WeChat draft publishing
This commit is contained in:
@@ -28,6 +28,28 @@
|
||||
<artifactId>ruoyi-system</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Markdown 公众号排版 -->
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark</artifactId>
|
||||
<version>0.21.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-gfm-tables</artifactId>
|
||||
<version>0.21.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.commonmark</groupId>
|
||||
<artifactId>commonmark-ext-gfm-strikethrough</artifactId>
|
||||
<version>0.21.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.16.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.ruoyi.office.controller;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.office.domain.TtWechatAccount;
|
||||
import com.ruoyi.office.service.ITtWechatAccountService;
|
||||
import com.ruoyi.office.service.IWechatDraftService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/office/wechat/account")
|
||||
public class TtWechatAccountController extends BaseController
|
||||
{
|
||||
private final ITtWechatAccountService accountService;
|
||||
private final IWechatDraftService draftService;
|
||||
|
||||
public TtWechatAccountController(ITtWechatAccountService accountService, IWechatDraftService draftService)
|
||||
{
|
||||
this.accountService = accountService;
|
||||
this.draftService = draftService;
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('office:wechat:account:list')")
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo list(TtWechatAccount account)
|
||||
{
|
||||
startPage();
|
||||
return getDataTable(accountService.selectList(account));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasAnyPermi('office:wechat:account:list,office:wechat:draft:push')")
|
||||
@GetMapping("/enabled")
|
||||
public AjaxResult enabled()
|
||||
{
|
||||
List<TtWechatAccount> list = accountService.selectEnabled();
|
||||
return success(list);
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('office:wechat:account:query')")
|
||||
@GetMapping("/{accountId}")
|
||||
public AjaxResult getInfo(@PathVariable Long accountId)
|
||||
{
|
||||
return success(accountService.selectById(accountId));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('office:wechat:account:add')")
|
||||
@Log(title = "公众号账号", businessType = BusinessType.INSERT)
|
||||
@PostMapping
|
||||
public AjaxResult add(@RequestBody TtWechatAccount account)
|
||||
{
|
||||
account.setCreateBy(SecurityUtils.getUsername());
|
||||
return toAjax(accountService.insert(account));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('office:wechat:account:edit')")
|
||||
@Log(title = "公众号账号", businessType = BusinessType.UPDATE)
|
||||
@PutMapping
|
||||
public AjaxResult edit(@RequestBody TtWechatAccount account)
|
||||
{
|
||||
account.setUpdateBy(SecurityUtils.getUsername());
|
||||
return toAjax(accountService.update(account));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('office:wechat:account:remove')")
|
||||
@Log(title = "公众号账号", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{accountIds}")
|
||||
public AjaxResult remove(@PathVariable Long[] accountIds)
|
||||
{
|
||||
return toAjax(accountService.delete(accountIds));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('office:wechat:account:test')")
|
||||
@PostMapping("/{accountId}/test")
|
||||
public AjaxResult test(@PathVariable Long accountId)
|
||||
{
|
||||
draftService.testAccount(accountId);
|
||||
return success("连接成功");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ruoyi.office.controller;
|
||||
|
||||
import com.ruoyi.common.annotation.Log;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.enums.BusinessType;
|
||||
import com.ruoyi.office.domain.TtWechatArticle;
|
||||
import com.ruoyi.office.domain.WechatDraftPushRequest;
|
||||
import com.ruoyi.office.service.IWechatDraftService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/office/wechat/draft")
|
||||
public class TtWechatDraftController extends BaseController
|
||||
{
|
||||
private final IWechatDraftService draftService;
|
||||
|
||||
public TtWechatDraftController(IWechatDraftService draftService)
|
||||
{
|
||||
this.draftService = draftService;
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('office:wechat:draft:list')")
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo list(TtWechatArticle article)
|
||||
{
|
||||
startPage();
|
||||
return getDataTable(draftService.selectList(article));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('office:wechat:draft:push')")
|
||||
@PostMapping("/preview")
|
||||
public AjaxResult preview(@RequestBody WechatDraftPushRequest request)
|
||||
{
|
||||
return success(draftService.preview(request));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('office:wechat:draft:push')")
|
||||
@Log(title = "公众号草稿", businessType = BusinessType.INSERT)
|
||||
@PostMapping
|
||||
public AjaxResult push(@RequestBody WechatDraftPushRequest request)
|
||||
{
|
||||
return AjaxResult.success("已推送到公众号草稿箱", draftService.push(request));
|
||||
}
|
||||
|
||||
@PreAuthorize("@ss.hasPermi('office:wechat:draft:retry')")
|
||||
@Log(title = "公众号草稿重试", businessType = BusinessType.UPDATE)
|
||||
@PostMapping("/{articleId}/retry")
|
||||
public AjaxResult retry(@PathVariable Long articleId)
|
||||
{
|
||||
return AjaxResult.success("重试成功,已推送到草稿箱", draftService.retry(articleId));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,14 @@ public class TtWechatAccount extends BaseEntity
|
||||
|
||||
private Boolean secretConfigured;
|
||||
|
||||
private String defaultAuthor;
|
||||
|
||||
private String defaultSourceUrl;
|
||||
|
||||
private Integer needOpenComment;
|
||||
|
||||
private Integer onlyFansCanComment;
|
||||
|
||||
public Long getAccountId()
|
||||
{
|
||||
return accountId;
|
||||
@@ -83,4 +91,13 @@ public class TtWechatAccount extends BaseEntity
|
||||
{
|
||||
this.secretConfigured = secretConfigured;
|
||||
}
|
||||
|
||||
public String getDefaultAuthor() { return defaultAuthor; }
|
||||
public void setDefaultAuthor(String defaultAuthor) { this.defaultAuthor = defaultAuthor; }
|
||||
public String getDefaultSourceUrl() { return defaultSourceUrl; }
|
||||
public void setDefaultSourceUrl(String defaultSourceUrl) { this.defaultSourceUrl = defaultSourceUrl; }
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -14,10 +14,14 @@ public class TtWechatArticle extends BaseEntity
|
||||
private Long articleId;
|
||||
private Long accountId;
|
||||
private String accountName;
|
||||
private Long codeId;
|
||||
private Long templateId;
|
||||
private String requestNo;
|
||||
private String title;
|
||||
private String author;
|
||||
private String digest;
|
||||
private String contentMarkdown;
|
||||
private String renderedHtml;
|
||||
private String contentHtml;
|
||||
private String contentSourceUrl;
|
||||
private String thumbMediaId;
|
||||
@@ -29,6 +33,7 @@ public class TtWechatArticle extends BaseEntity
|
||||
private String pushStatus;
|
||||
private Integer errorCode;
|
||||
private String errorMessage;
|
||||
private Integer retryCount;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date pushTime;
|
||||
@@ -39,6 +44,10 @@ public class TtWechatArticle extends BaseEntity
|
||||
public void setAccountId(Long accountId) { this.accountId = accountId; }
|
||||
public String getAccountName() { return accountName; }
|
||||
public void setAccountName(String accountName) { this.accountName = accountName; }
|
||||
public Long getCodeId() { return codeId; }
|
||||
public void setCodeId(Long codeId) { this.codeId = codeId; }
|
||||
public Long getTemplateId() { return templateId; }
|
||||
public void setTemplateId(Long templateId) { this.templateId = templateId; }
|
||||
public String getRequestNo() { return requestNo; }
|
||||
public void setRequestNo(String requestNo) { this.requestNo = requestNo; }
|
||||
public String getTitle() { return title; }
|
||||
@@ -47,6 +56,10 @@ public class TtWechatArticle extends BaseEntity
|
||||
public void setAuthor(String author) { this.author = author; }
|
||||
public String getDigest() { return digest; }
|
||||
public void setDigest(String digest) { this.digest = digest; }
|
||||
public String getContentMarkdown() { return contentMarkdown; }
|
||||
public void setContentMarkdown(String contentMarkdown) { this.contentMarkdown = contentMarkdown; }
|
||||
public String getRenderedHtml() { return renderedHtml; }
|
||||
public void setRenderedHtml(String renderedHtml) { this.renderedHtml = renderedHtml; }
|
||||
public String getContentHtml() { return contentHtml; }
|
||||
public void setContentHtml(String contentHtml) { this.contentHtml = contentHtml; }
|
||||
public String getContentSourceUrl() { return contentSourceUrl; }
|
||||
@@ -67,6 +80,8 @@ public class TtWechatArticle extends BaseEntity
|
||||
public void setErrorCode(Integer errorCode) { this.errorCode = errorCode; }
|
||||
public String getErrorMessage() { return errorMessage; }
|
||||
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||
public Integer getRetryCount() { return retryCount; }
|
||||
public void setRetryCount(Integer retryCount) { this.retryCount = retryCount; }
|
||||
public Date getPushTime() { return pushTime; }
|
||||
public void setPushTime(Date pushTime) { this.pushTime = pushTime; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ruoyi.office.domain;
|
||||
|
||||
/**
|
||||
* 公众号草稿预览结果。
|
||||
*/
|
||||
public class WechatDraftPreview
|
||||
{
|
||||
private String requestNo;
|
||||
private String title;
|
||||
private String author;
|
||||
private String digest;
|
||||
private String contentMarkdown;
|
||||
private String contentHtml;
|
||||
private String contentSourceUrl;
|
||||
private Integer needOpenComment;
|
||||
private Integer onlyFansCanComment;
|
||||
|
||||
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 getContentMarkdown() { return contentMarkdown; }
|
||||
public void setContentMarkdown(String contentMarkdown) { this.contentMarkdown = contentMarkdown; }
|
||||
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 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; }
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
package com.ruoyi.office.domain;
|
||||
|
||||
/**
|
||||
* 直接同步公众号草稿箱的请求对象。contentHtml 必须是调用方准备好的 HTML。
|
||||
* 公众号草稿预览及推送请求对象。
|
||||
*/
|
||||
public class WechatDraftPushRequest
|
||||
{
|
||||
private Long accountId;
|
||||
private Long codeId;
|
||||
private Long templateId;
|
||||
private String requestNo;
|
||||
private String title;
|
||||
private String author;
|
||||
private String digest;
|
||||
private String contentHtml;
|
||||
private String contentMarkdown;
|
||||
/** 排版主题:default 经典、grace 优雅、simple 简洁。 */
|
||||
private String themeName;
|
||||
private String themeColor;
|
||||
/** 正文字号,支持 14-18px。 */
|
||||
private Integer fontSize;
|
||||
private String contentSourceUrl;
|
||||
private String thumbMediaId;
|
||||
private Integer showCoverPic;
|
||||
@@ -19,6 +27,10 @@ public class WechatDraftPushRequest
|
||||
|
||||
public Long getAccountId() { return accountId; }
|
||||
public void setAccountId(Long accountId) { this.accountId = accountId; }
|
||||
public Long getCodeId() { return codeId; }
|
||||
public void setCodeId(Long codeId) { this.codeId = codeId; }
|
||||
public Long getTemplateId() { return templateId; }
|
||||
public void setTemplateId(Long templateId) { this.templateId = templateId; }
|
||||
public String getRequestNo() { return requestNo; }
|
||||
public void setRequestNo(String requestNo) { this.requestNo = requestNo; }
|
||||
public String getTitle() { return title; }
|
||||
@@ -29,6 +41,14 @@ public class WechatDraftPushRequest
|
||||
public void setDigest(String digest) { this.digest = digest; }
|
||||
public String getContentHtml() { return contentHtml; }
|
||||
public void setContentHtml(String contentHtml) { this.contentHtml = contentHtml; }
|
||||
public String getContentMarkdown() { return contentMarkdown; }
|
||||
public void setContentMarkdown(String contentMarkdown) { this.contentMarkdown = contentMarkdown; }
|
||||
public String getThemeName() { return themeName; }
|
||||
public void setThemeName(String themeName) { this.themeName = themeName; }
|
||||
public String getThemeColor() { return themeColor; }
|
||||
public void setThemeColor(String themeColor) { this.themeColor = themeColor; }
|
||||
public Integer getFontSize() { return fontSize; }
|
||||
public void setFontSize(Integer fontSize) { this.fontSize = fontSize; }
|
||||
public String getContentSourceUrl() { return contentSourceUrl; }
|
||||
public void setContentSourceUrl(String contentSourceUrl) { this.contentSourceUrl = contentSourceUrl; }
|
||||
public String getThumbMediaId() { return thumbMediaId; }
|
||||
|
||||
@@ -14,4 +14,6 @@ public interface TtWechatArticleMapper
|
||||
int insertTtWechatArticle(TtWechatArticle article);
|
||||
|
||||
int updateTtWechatArticleResult(TtWechatArticle article);
|
||||
|
||||
int updateTtWechatArticleProcessing(TtWechatArticle article);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.office.domain.TtWechatAccount;
|
||||
|
||||
public interface ITtWechatAccountService
|
||||
{
|
||||
TtWechatAccount selectById(Long accountId);
|
||||
List<TtWechatAccount> selectList(TtWechatAccount account);
|
||||
List<TtWechatAccount> selectEnabled();
|
||||
int insert(TtWechatAccount account);
|
||||
int update(TtWechatAccount account);
|
||||
int delete(Long[] accountIds);
|
||||
TtWechatAccount selectUsableWithSecret(Long accountId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.office.domain.TtWechatArticle;
|
||||
import com.ruoyi.office.domain.WechatDraftPreview;
|
||||
import com.ruoyi.office.domain.WechatDraftPushRequest;
|
||||
|
||||
public interface IWechatDraftService
|
||||
{
|
||||
WechatDraftPreview preview(WechatDraftPushRequest request);
|
||||
TtWechatArticle push(WechatDraftPushRequest request);
|
||||
TtWechatArticle retry(Long articleId);
|
||||
TtWechatArticle selectById(Long articleId);
|
||||
List<TtWechatArticle> selectList(TtWechatArticle article);
|
||||
void testAccount(Long accountId);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import com.ruoyi.office.domain.TtWechatAccount;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class WechatArticleImageService
|
||||
{
|
||||
private final WechatImageProcessor imageProcessor;
|
||||
private final WechatMpClient mpClient;
|
||||
|
||||
public WechatArticleImageService(WechatImageProcessor imageProcessor, WechatMpClient mpClient)
|
||||
{
|
||||
this.imageProcessor = imageProcessor;
|
||||
this.mpClient = mpClient;
|
||||
}
|
||||
|
||||
public String rewriteImages(String html, TtWechatAccount account)
|
||||
{
|
||||
Document document = Jsoup.parseBodyFragment(html);
|
||||
document.outputSettings().prettyPrint(false);
|
||||
for (Element image : document.select("img[src]"))
|
||||
{
|
||||
String source = image.attr("src");
|
||||
if (isWechatImage(source)) continue;
|
||||
byte[] data = imageProcessor.downloadArticleImage(source);
|
||||
image.attr("src", mpClient.uploadContentImage(account, data));
|
||||
}
|
||||
return document.body().html();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载正文中的第一张图片,并转换为微信封面素材要求的 JPEG。
|
||||
* 正文没有图片时返回 null,由调用方决定是否使用兜底封面。
|
||||
*/
|
||||
public byte[] downloadFirstImageCover(String html)
|
||||
{
|
||||
Document document = Jsoup.parseBodyFragment(html == null ? "" : html);
|
||||
Element firstImage = document.selectFirst("img[src]");
|
||||
if (firstImage == null) return null;
|
||||
String source = firstImage.attr("src");
|
||||
if (source == null || source.trim().isEmpty()) return null;
|
||||
return imageProcessor.downloadCoverImage(source);
|
||||
}
|
||||
|
||||
private boolean isWechatImage(String url)
|
||||
{
|
||||
return url != null && (url.startsWith("https://mmbiz.qpic.cn/") || url.startsWith("http://mmbiz.qpic.cn/"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import org.commonmark.Extension;
|
||||
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension;
|
||||
import org.commonmark.ext.gfm.tables.TablesExtension;
|
||||
import org.commonmark.node.Node;
|
||||
import org.commonmark.parser.Parser;
|
||||
import org.commonmark.renderer.html.HtmlRenderer;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.safety.Safelist;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 将 Markdown 转换为可直接提交给微信的内联样式 HTML。
|
||||
*/
|
||||
@Component
|
||||
public class WechatArticleRenderer
|
||||
{
|
||||
private static final String DEFAULT_COLOR = "#07c160";
|
||||
private static final String DEFAULT_THEME = "default";
|
||||
private static final int DEFAULT_FONT_SIZE = 16;
|
||||
private static final Pattern HEX_COLOR = Pattern.compile("^#[0-9a-fA-F]{6}$");
|
||||
private final Parser parser;
|
||||
private final HtmlRenderer renderer;
|
||||
|
||||
public WechatArticleRenderer()
|
||||
{
|
||||
List<Extension> extensions = Arrays.asList(
|
||||
TablesExtension.create(), StrikethroughExtension.create());
|
||||
this.parser = Parser.builder().extensions(extensions).build();
|
||||
// 公众号排版按编辑器中的单换行展示,避免 CommonMark 软换行被浏览器折叠为空格。
|
||||
this.renderer = HtmlRenderer.builder().extensions(extensions).softbreak("<br />\n").build();
|
||||
}
|
||||
|
||||
public String render(String markdown, String themeColor)
|
||||
{
|
||||
return render(markdown, themeColor, DEFAULT_THEME);
|
||||
}
|
||||
|
||||
public String render(String markdown, String themeColor, String themeName)
|
||||
{
|
||||
return render(markdown, themeColor, themeName, DEFAULT_FONT_SIZE);
|
||||
}
|
||||
|
||||
public String render(String markdown, String themeColor, String themeName, Integer fontSize)
|
||||
{
|
||||
String color = HEX_COLOR.matcher(themeColor == null ? "" : themeColor).matches()
|
||||
? themeColor : DEFAULT_COLOR;
|
||||
String theme = normalizeTheme(themeName);
|
||||
int normalizedFontSize = normalizeFontSize(fontSize);
|
||||
Node documentNode = parser.parse(markdown == null ? "" : markdown);
|
||||
String rawHtml = renderer.render(documentNode);
|
||||
Safelist safelist = Safelist.none()
|
||||
.addTags("section", "p", "br", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"strong", "b", "em", "i", "s", "del", "blockquote", "ul", "ol", "li",
|
||||
"pre", "code", "table", "thead", "tbody", "tr", "th", "td", "a", "img",
|
||||
"hr", "span", "div", "center")
|
||||
.addAttributes("a", "href", "title")
|
||||
.addAttributes("img", "src", "alt", "title", "width", "height")
|
||||
.addProtocols("a", "href", "http", "https")
|
||||
.addProtocols("img", "src", "http", "https");
|
||||
Document.OutputSettings settings = new Document.OutputSettings().prettyPrint(false);
|
||||
String cleaned = Jsoup.clean(rawHtml, "", safelist, settings);
|
||||
Document doc = Jsoup.parseBodyFragment(cleaned);
|
||||
doc.outputSettings().prettyPrint(false);
|
||||
|
||||
applyClassicTheme(doc, color, normalizedFontSize);
|
||||
if ("grace".equals(theme))
|
||||
{
|
||||
applyGraceTheme(doc, color, normalizedFontSize);
|
||||
}
|
||||
else if ("simple".equals(theme))
|
||||
{
|
||||
applySimpleTheme(doc, color, normalizedFontSize);
|
||||
}
|
||||
|
||||
String inner = doc.body().html();
|
||||
return "<section style=\"box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,'Helvetica Neue','PingFang SC','Microsoft YaHei',Arial,sans-serif;color:#3f3f3f;font-size:"
|
||||
+ normalizedFontSize + "px;line-height:1.75;\">"
|
||||
+ inner + "</section>";
|
||||
}
|
||||
|
||||
private void applyClassicTheme(Document doc, String color, int fontSize)
|
||||
{
|
||||
style(doc.select("p"), "margin:24px 8px;line-height:1.75;font-size:" + px(fontSize) + ";color:#3f3f3f;letter-spacing:0.1em;");
|
||||
style(doc.select("h1"), "display:table;padding:0 1em;border-bottom:2px solid " + color + ";margin:32px auto 16px;color:#3f3f3f;font-size:" + scaledPx(fontSize, 1.2) + ";font-weight:bold;text-align:center;line-height:1.5;");
|
||||
style(doc.select("h2"), "display:table;padding:0 0.2em;margin:64px auto 32px;color:#fff;background:" + color + ";font-size:" + scaledPx(fontSize, 1.2) + ";font-weight:bold;text-align:center;line-height:1.5;");
|
||||
style(doc.select("h3"), "padding-left:8px;border-left:3px solid " + color + ";margin:32px 8px 12px 0;color:#3f3f3f;font-size:" + scaledPx(fontSize, 1.1) + ";font-weight:bold;line-height:1.2;");
|
||||
style(doc.select("h4"), "margin:32px 8px 8px;color:" + color + ";font-size:" + px(fontSize) + ";font-weight:bold;line-height:1.5;");
|
||||
style(doc.select("h5,h6"), "margin:24px 8px 8px;color:" + color + ";font-size:" + px(fontSize) + ";font-weight:bold;line-height:1.5;");
|
||||
style(doc.select("strong,b"), "font-weight:bold;color:" + color + ";font-size:inherit;");
|
||||
style(doc.select("blockquote"), "margin:0 0 16px;padding:16px;border-left:4px solid " + color + ";border-radius:6px;background:#f7f7f7;color:#3f3f3f;line-height:1.75;");
|
||||
style(doc.select("blockquote > p"), "display:block;margin:0;font-size:" + px(fontSize) + ";letter-spacing:0.1em;color:#3f3f3f;");
|
||||
style(doc.select("ul,ol"), "margin:14px 0;padding-left:28px;color:#3f3f3f;");
|
||||
style(doc.select("li"), "display:list-item;margin:3px 8px;line-height:1.75;font-size:" + px(fontSize) + ";color:#3f3f3f;");
|
||||
style(doc.select("a"), "color:#576b95;text-decoration:none;");
|
||||
style(doc.select("img"), "display:block;max-width:100%;height:auto;margin:2px auto 8px;border-radius:4px;");
|
||||
style(doc.select("pre"), "margin:10px 8px;padding:16px;overflow-x:auto;background:#282c34;border-radius:8px;line-height:1.5;");
|
||||
style(doc.select("pre code"), "padding:0;background:transparent;color:#abb2bf;font-family:Consolas,Monaco,monospace;font-size:90%;white-space:pre-wrap;");
|
||||
style(doc.select("code:not(pre code)"), "padding:3px 5px;background:" + rgba(color, 0.08) + ";color:" + color + ";border:1px solid " + rgba(color, 0.20) + ";border-radius:4px;font-family:Consolas,Monaco,monospace;font-size:90%;");
|
||||
style(doc.select("table"), "width:100%;min-width:100%;margin:18px 0;border-collapse:collapse;font-size:90%;color:#3f3f3f;");
|
||||
style(doc.select("thead"), "font-weight:bold;color:#3f3f3f;");
|
||||
style(doc.select("th"), "padding:4px 8px;border:1px solid #d9d9d9;background:#f5f7fa;font-weight:bold;color:#3f3f3f;word-break:keep-all;");
|
||||
style(doc.select("td"), "padding:4px 8px;border:1px solid #d9d9d9;color:#3f3f3f;word-break:keep-all;");
|
||||
style(doc.select("hr"), "height:6px;margin:24px 0;border:0;border-top:1px solid #e5e5e5;");
|
||||
style(doc.select("center"), "text-align:center;");
|
||||
}
|
||||
|
||||
private void applyGraceTheme(Document doc, String color, int fontSize)
|
||||
{
|
||||
appendStyle(doc.select("h1"), "padding:0.5em 1em;font-size:" + scaledPx(fontSize, 1.4) + ";text-shadow:2px 2px 4px rgba(0,0,0,0.1);");
|
||||
appendStyle(doc.select("h2"), "padding:0.3em 1em;border-radius:8px;font-size:" + scaledPx(fontSize, 1.3) + ";box-shadow:0 4px 6px rgba(0,0,0,0.1);");
|
||||
appendStyle(doc.select("h3"), "padding-left:12px;font-size:" + scaledPx(fontSize, 1.2) + ";border-left:4px solid " + color + ";border-bottom:1px dashed " + color + ";");
|
||||
appendStyle(doc.select("h4"), "font-size:" + scaledPx(fontSize, 1.1) + ";");
|
||||
appendStyle(doc.select("blockquote"), "font-style:italic;padding:16px 16px 16px 32px;background:transparent;box-shadow:0 4px 6px rgba(0,0,0,0.05);");
|
||||
appendStyle(doc.select("img"), "border-radius:8px;box-shadow:0 4px 8px rgba(0,0,0,0.1);");
|
||||
appendStyle(doc.select("ul,ol"), "padding-left:24px;");
|
||||
appendStyle(doc.select("li"), "margin:8px;");
|
||||
appendStyle(doc.select("hr"), "height:1px;border:0;margin:32px 0;background:#e5e5e5;");
|
||||
appendStyle(doc.select("table"), "border-collapse:separate;border-spacing:0;border-radius:8px;margin:16px 8px;box-shadow:0 4px 6px rgba(0,0,0,0.1);overflow:hidden;");
|
||||
appendStyle(doc.select("thead,thead th"), "color:#fff;background:" + color + ";");
|
||||
appendStyle(doc.select("td"), "padding:8px 16px;");
|
||||
}
|
||||
|
||||
private void applySimpleTheme(Document doc, String color, int fontSize)
|
||||
{
|
||||
appendStyle(doc.select("h1"), "padding:0.5em 1em;font-size:" + scaledPx(fontSize, 1.4) + ";text-shadow:1px 1px 3px rgba(0,0,0,0.05);");
|
||||
appendStyle(doc.select("h2"), "padding:0.3em 1.2em;font-size:" + scaledPx(fontSize, 1.3) + ";border-radius:8px 24px 8px 24px;box-shadow:0 2px 6px rgba(0,0,0,0.06);");
|
||||
appendStyle(doc.select("h3"), "padding-left:12px;font-size:" + scaledPx(fontSize, 1.2) + ";border-radius:6px;line-height:2.4;border:1px solid " + rgba(color, 0.10) + ";border-left:4px solid " + color + ";background:" + rgba(color, 0.08) + ";");
|
||||
appendStyle(doc.select("h4,h5,h6"), "border-radius:6px;");
|
||||
appendStyle(doc.select("blockquote"), "font-style:italic;padding:16px 16px 16px 32px;background:transparent;border:1px solid rgba(0,0,0,0.04);border-left:4px solid " + color + ";");
|
||||
appendStyle(doc.select("img"), "border-radius:8px;border:1px solid rgba(0,0,0,0.04);");
|
||||
appendStyle(doc.select("ul,ol"), "padding-left:24px;");
|
||||
appendStyle(doc.select("li"), "margin:8px;");
|
||||
appendStyle(doc.select("hr"), "height:1px;border:0;margin:32px 0;background:#e5e5e5;");
|
||||
}
|
||||
|
||||
private String normalizeTheme(String themeName)
|
||||
{
|
||||
return "grace".equals(themeName) || "simple".equals(themeName) ? themeName : DEFAULT_THEME;
|
||||
}
|
||||
|
||||
private int normalizeFontSize(Integer fontSize)
|
||||
{
|
||||
return fontSize != null && fontSize >= 14 && fontSize <= 18 ? fontSize : DEFAULT_FONT_SIZE;
|
||||
}
|
||||
|
||||
private String px(int fontSize)
|
||||
{
|
||||
return fontSize + "px";
|
||||
}
|
||||
|
||||
private String scaledPx(int fontSize, double scale)
|
||||
{
|
||||
double value = Math.round(fontSize * scale * 10.0) / 10.0;
|
||||
return value == (int) value ? (int) value + "px" : value + "px";
|
||||
}
|
||||
|
||||
private String rgba(String hex, double alpha)
|
||||
{
|
||||
int red = Integer.parseInt(hex.substring(1, 3), 16);
|
||||
int green = Integer.parseInt(hex.substring(3, 5), 16);
|
||||
int blue = Integer.parseInt(hex.substring(5, 7), 16);
|
||||
return "rgba(" + red + "," + green + "," + blue + "," + alpha + ")";
|
||||
}
|
||||
|
||||
private void style(Iterable<Element> elements, String style)
|
||||
{
|
||||
for (Element element : elements)
|
||||
{
|
||||
element.attr("style", style);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendStyle(Iterable<Element> elements, String style)
|
||||
{
|
||||
for (Element element : elements)
|
||||
{
|
||||
String current = element.attr("style");
|
||||
element.attr("style", current + (current.endsWith(";") || current.isEmpty() ? "" : ";") + style);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import com.ruoyi.common.utils.CoverGenerator;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.office.domain.TtCode;
|
||||
import com.ruoyi.office.domain.TtProjectInfo;
|
||||
import com.ruoyi.office.mapper.TtProjectInfoMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class WechatCoverService
|
||||
{
|
||||
private final TtProjectInfoMapper projectInfoMapper;
|
||||
private final WechatImageProcessor imageProcessor;
|
||||
|
||||
public WechatCoverService(TtProjectInfoMapper projectInfoMapper, WechatImageProcessor imageProcessor)
|
||||
{
|
||||
this.projectInfoMapper = projectInfoMapper;
|
||||
this.imageProcessor = imageProcessor;
|
||||
}
|
||||
|
||||
public byte[] createCover(TtCode code)
|
||||
{
|
||||
TtProjectInfo project = projectInfoMapper.selectTtProjectInfoByName(code.getCodeName());
|
||||
String projectName = project == null ? code.getCodeName()
|
||||
: firstNonBlank(project.getProjectName1(), project.getProjectName(), code.getCodeName());
|
||||
String title = project == null ? extractProjectCode(code.getCodeName())
|
||||
: firstNonBlank(project.getProjectNum1(), project.getProjectNum(), extractProjectCode(code.getCodeName()));
|
||||
String subtitle = projectName;
|
||||
if (subtitle.contains("的")) subtitle = subtitle.substring(subtitle.lastIndexOf('的') + 1).trim();
|
||||
String tag = project == null ? code.getCodeTechnology()
|
||||
: firstNonBlank(project.getProjectDesc(), code.getCodeTechnology(), "项目源码分享");
|
||||
int titleSize = subtitle.length() > 8 ? 80 : 120;
|
||||
BufferedImage cover = CoverGenerator.createCover(title, subtitle, tag, titleSize);
|
||||
return imageProcessor.toCoverJpeg(cover);
|
||||
}
|
||||
|
||||
private String extractProjectCode(String codeName)
|
||||
{
|
||||
if (codeName != null && codeName.contains("【") && codeName.contains("】"))
|
||||
{
|
||||
return codeName.substring(codeName.indexOf('【') + 1, codeName.indexOf('】'));
|
||||
}
|
||||
return "项目分享";
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values)
|
||||
{
|
||||
for (String value : values) if (StringUtils.isNotBlank(value)) return value;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import java.util.UUID;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.office.domain.TtCode;
|
||||
import com.ruoyi.office.domain.TtCopyTemplate;
|
||||
import com.ruoyi.office.domain.TtWechatAccount;
|
||||
import com.ruoyi.office.domain.WebsiteArticleDocument;
|
||||
import com.ruoyi.office.domain.WechatDraftPreview;
|
||||
import com.ruoyi.office.domain.WechatDraftPushRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 组装公众号文章 Markdown 及预览 HTML。
|
||||
*/
|
||||
@Service
|
||||
public class WechatDraftContentService
|
||||
{
|
||||
private final ITtCodeService codeService;
|
||||
private final ITtCopyTemplateService templateService;
|
||||
private final ITtWechatAccountService accountService;
|
||||
private final CopyTemplateRenderer templateRenderer;
|
||||
private final WebsiteArticleDocumentParser documentParser;
|
||||
private final WechatArticleRenderer articleRenderer;
|
||||
|
||||
public WechatDraftContentService(ITtCodeService codeService,
|
||||
ITtCopyTemplateService templateService,
|
||||
ITtWechatAccountService accountService,
|
||||
CopyTemplateRenderer templateRenderer,
|
||||
WebsiteArticleDocumentParser documentParser,
|
||||
WechatArticleRenderer articleRenderer)
|
||||
{
|
||||
this.codeService = codeService;
|
||||
this.templateService = templateService;
|
||||
this.accountService = accountService;
|
||||
this.templateRenderer = templateRenderer;
|
||||
this.documentParser = documentParser;
|
||||
this.articleRenderer = articleRenderer;
|
||||
}
|
||||
|
||||
public WechatDraftPreview preview(WechatDraftPushRequest request)
|
||||
{
|
||||
if (request == null || request.getCodeId() == null || request.getTemplateId() == null)
|
||||
{
|
||||
throw new ServiceException("项目和文案模板不能为空");
|
||||
}
|
||||
TtCode code = codeService.selectTtCodeByCodeId(request.getCodeId());
|
||||
TtCopyTemplate template = templateService.selectTtCopyTemplateByTemplateId(request.getTemplateId());
|
||||
if (code == null) throw new ServiceException("源码项目不存在");
|
||||
if (template == null || !"0".equals(template.getStatus())) throw new ServiceException("文案模板不存在或已停用");
|
||||
|
||||
WebsiteArticleDocument generated = documentParser.parse(templateRenderer.render(template, code));
|
||||
String markdown = StringUtils.isNotBlank(request.getContentMarkdown())
|
||||
? request.getContentMarkdown() : generated.getContentMarkdown();
|
||||
String title = StringUtils.isNotBlank(request.getTitle()) ? request.getTitle() : generated.getTitle();
|
||||
TtWechatAccount account = request.getAccountId() == null ? null : accountService.selectById(request.getAccountId());
|
||||
|
||||
WechatDraftPreview preview = new WechatDraftPreview();
|
||||
preview.setRequestNo(StringUtils.isNotBlank(request.getRequestNo())
|
||||
? request.getRequestNo() : UUID.randomUUID().toString().replace("-", ""));
|
||||
preview.setTitle(limit(title, 32));
|
||||
preview.setAuthor(limit(firstNonBlank(request.getAuthor(), account == null ? null : account.getDefaultAuthor()), 16));
|
||||
String html = articleRenderer.render(markdown, request.getThemeColor(), request.getThemeName(), request.getFontSize());
|
||||
// 摘要默认留空,交由微信使用正文内容生成展示摘要;手动摘要遵循微信 120 字限制。
|
||||
preview.setDigest(limit(request.getDigest(), 120));
|
||||
preview.setContentMarkdown(markdown);
|
||||
preview.setContentHtml(html);
|
||||
preview.setContentSourceUrl(firstNonBlank(request.getContentSourceUrl(), account == null ? null : account.getDefaultSourceUrl()));
|
||||
preview.setNeedOpenComment(firstNonNull(request.getNeedOpenComment(), account == null ? null : account.getNeedOpenComment(), 0));
|
||||
preview.setOnlyFansCanComment(firstNonNull(request.getOnlyFansCanComment(), account == null ? null : account.getOnlyFansCanComment(), 0));
|
||||
return preview;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String first, String second)
|
||||
{
|
||||
return StringUtils.isNotBlank(first) ? first : (second == null ? "" : second);
|
||||
}
|
||||
|
||||
private Integer firstNonNull(Integer first, Integer second, Integer fallback)
|
||||
{
|
||||
return first != null ? first : (second != null ? second : fallback);
|
||||
}
|
||||
|
||||
private String limit(String value, int max)
|
||||
{
|
||||
if (value == null) return "";
|
||||
return value.length() <= max ? value : value.substring(0, max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.IDN;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Iterator;
|
||||
import javax.imageio.IIOImage;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
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 com.ruoyi.common.exception.ServiceException;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 下载、压缩公众号图片,并拒绝内网地址。 */
|
||||
@Component
|
||||
public class WechatImageProcessor
|
||||
{
|
||||
private static final int MAX_DOWNLOAD_BYTES = 8 * 1024 * 1024;
|
||||
private static final String INSECURE_IMAGE_HOST = "img.yidaima.cn";
|
||||
private static final SSLSocketFactory INSECURE_IMAGE_SSL_SOCKET_FACTORY = createInsecureSslSocketFactory();
|
||||
|
||||
public byte[] downloadArticleImage(String sourceUrl)
|
||||
{
|
||||
return toJpeg(download(sourceUrl), 1080, 950 * 1024);
|
||||
}
|
||||
|
||||
public byte[] downloadCoverImage(String sourceUrl)
|
||||
{
|
||||
return toJpeg(download(sourceUrl), 900, 60 * 1024);
|
||||
}
|
||||
|
||||
public byte[] toCoverJpeg(BufferedImage image)
|
||||
{
|
||||
return toJpeg(image, 900, 60 * 1024);
|
||||
}
|
||||
|
||||
private byte[] download(String sourceUrl)
|
||||
{
|
||||
String current = sourceUrl;
|
||||
for (int redirect = 0; redirect < 4; redirect++)
|
||||
{
|
||||
try
|
||||
{
|
||||
URI uri = toRequestUri(current);
|
||||
validatePublicHttpUrl(uri);
|
||||
HttpURLConnection connection = (HttpURLConnection) uri.toURL().openConnection();
|
||||
configureScopedTlsBypass(connection, uri);
|
||||
connection.setConnectTimeout(10000);
|
||||
connection.setReadTimeout(30000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
connection.setRequestProperty("User-Agent", "RuoYi-Wechat-Draft/1.0");
|
||||
int status = connection.getResponseCode();
|
||||
if (status >= 300 && status < 400)
|
||||
{
|
||||
String location = connection.getHeaderField("Location");
|
||||
if (location == null) throw new ServiceException("图片重定向缺少 Location");
|
||||
current = new URL(uri.toURL(), location).toString();
|
||||
continue;
|
||||
}
|
||||
if (status < 200 || status >= 300) throw new ServiceException("下载图片失败:HTTP " + status);
|
||||
String contentType = connection.getContentType();
|
||||
if (contentType == null || !contentType.toLowerCase().startsWith("image/"))
|
||||
{
|
||||
throw new ServiceException("正文图片地址返回的不是图片");
|
||||
}
|
||||
try (InputStream input = connection.getInputStream(); ByteArrayOutputStream output = new ByteArrayOutputStream())
|
||||
{
|
||||
byte[] buffer = new byte[8192];
|
||||
int total = 0;
|
||||
int count;
|
||||
while ((count = input.read(buffer)) >= 0)
|
||||
{
|
||||
total += count;
|
||||
if (total > MAX_DOWNLOAD_BYTES) throw new ServiceException("正文图片超过8MB");
|
||||
output.write(buffer, 0, count);
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new ServiceException("下载正文图片失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
throw new ServiceException("正文图片重定向次数过多");
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅为 img.yidaima.cn 的图片连接绕过证书链与主机名校验。
|
||||
* 使用连接级配置,不修改 JVM 全局 HTTPS 行为,微信 API 和其他图片域名不受影响。
|
||||
*/
|
||||
private void configureScopedTlsBypass(HttpURLConnection connection, URI uri)
|
||||
{
|
||||
if (!(connection instanceof HttpsURLConnection) || !shouldSkipTlsValidation(uri)) return;
|
||||
HttpsURLConnection https = (HttpsURLConnection) connection;
|
||||
https.setSSLSocketFactory(INSECURE_IMAGE_SSL_SOCKET_FACTORY);
|
||||
https.setHostnameVerifier((hostname, session) -> INSECURE_IMAGE_HOST.equalsIgnoreCase(hostname));
|
||||
}
|
||||
|
||||
boolean shouldSkipTlsValidation(URI uri)
|
||||
{
|
||||
return uri != null && "https".equalsIgnoreCase(uri.getScheme())
|
||||
&& INSECURE_IMAGE_HOST.equalsIgnoreCase(uri.getHost());
|
||||
}
|
||||
|
||||
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 context = SSLContext.getInstance("TLS");
|
||||
context.init(null, trustManagers, new SecureRandom());
|
||||
return context.getSocketFactory();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new IllegalStateException("初始化公众号图片 HTTPS 配置失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将中文、空格等字符编码为可供 HttpURLConnection 请求的 ASCII URI,
|
||||
* 同时保留地址中已经存在的百分号编码,避免出现二次编码。
|
||||
*/
|
||||
URI toRequestUri(String sourceUrl) throws Exception
|
||||
{
|
||||
if (sourceUrl == null || sourceUrl.trim().isEmpty())
|
||||
{
|
||||
throw new ServiceException("正文图片地址不能为空");
|
||||
}
|
||||
URL parsed = new URL(sourceUrl.trim());
|
||||
if (parsed.getUserInfo() != null)
|
||||
{
|
||||
throw new ServiceException("正文图片地址不能包含用户凭据");
|
||||
}
|
||||
String host = parsed.getHost();
|
||||
if (host == null || host.isEmpty())
|
||||
{
|
||||
throw new ServiceException("正文图片地址缺少主机名");
|
||||
}
|
||||
if (!(host.startsWith("[") && host.endsWith("]")) && host.indexOf(':') < 0)
|
||||
{
|
||||
host = IDN.toASCII(host);
|
||||
}
|
||||
|
||||
StringBuilder ascii = new StringBuilder();
|
||||
ascii.append(parsed.getProtocol()).append("://");
|
||||
if (host.indexOf(':') >= 0 && !(host.startsWith("[") && host.endsWith("]")))
|
||||
{
|
||||
ascii.append('[').append(host).append(']');
|
||||
}
|
||||
else
|
||||
{
|
||||
ascii.append(host);
|
||||
}
|
||||
if (parsed.getPort() >= 0)
|
||||
{
|
||||
ascii.append(':').append(parsed.getPort());
|
||||
}
|
||||
ascii.append(percentEncode(parsed.getPath(), "-._~!$&'()*+,;=:@/"));
|
||||
if (parsed.getQuery() != null)
|
||||
{
|
||||
ascii.append('?').append(percentEncode(parsed.getQuery(), "-._~!$&'()*+,;=:@/?"));
|
||||
}
|
||||
return URI.create(ascii.toString());
|
||||
}
|
||||
|
||||
private String percentEncode(String value, String safeAscii)
|
||||
{
|
||||
StringBuilder encoded = new StringBuilder();
|
||||
for (int index = 0; index < value.length();)
|
||||
{
|
||||
int codePoint = value.codePointAt(index);
|
||||
if (codePoint == '%' && index + 2 < value.length()
|
||||
&& isHex(value.charAt(index + 1)) && isHex(value.charAt(index + 2)))
|
||||
{
|
||||
encoded.append(value, index, index + 3);
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
if (codePoint < 128 && (Character.isLetterOrDigit(codePoint)
|
||||
|| safeAscii.indexOf(codePoint) >= 0))
|
||||
{
|
||||
encoded.append((char) codePoint);
|
||||
}
|
||||
else
|
||||
{
|
||||
byte[] bytes = new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8);
|
||||
for (byte item : bytes)
|
||||
{
|
||||
encoded.append('%');
|
||||
int unsigned = item & 0xff;
|
||||
encoded.append(Character.toUpperCase(Character.forDigit(unsigned >>> 4, 16)));
|
||||
encoded.append(Character.toUpperCase(Character.forDigit(unsigned & 0x0f, 16)));
|
||||
}
|
||||
}
|
||||
index += Character.charCount(codePoint);
|
||||
}
|
||||
return encoded.toString();
|
||||
}
|
||||
|
||||
private boolean isHex(char value)
|
||||
{
|
||||
return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f')
|
||||
|| (value >= 'A' && value <= 'F');
|
||||
}
|
||||
|
||||
private void validatePublicHttpUrl(URI uri) throws Exception
|
||||
{
|
||||
String scheme = uri.getScheme();
|
||||
if (!("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) || uri.getHost() == null)
|
||||
{
|
||||
throw new ServiceException("只允许下载 HTTP/HTTPS 图片");
|
||||
}
|
||||
for (InetAddress address : InetAddress.getAllByName(uri.getHost()))
|
||||
{
|
||||
if (address.isAnyLocalAddress() || address.isLoopbackAddress() || address.isLinkLocalAddress()
|
||||
|| address.isSiteLocalAddress() || address.isMulticastAddress())
|
||||
{
|
||||
throw new ServiceException("禁止访问内网图片地址");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] toJpeg(byte[] source, int maxWidth, int maxBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(source));
|
||||
if (image == null) throw new ServiceException("无法解析图片");
|
||||
return toJpeg(image, maxWidth, maxBytes);
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
throw new ServiceException("图片转换失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] toJpeg(BufferedImage source, int maxWidth, int maxBytes)
|
||||
{
|
||||
BufferedImage image = normalize(source, maxWidth);
|
||||
float quality = 0.88f;
|
||||
for (int attempt = 0; attempt < 12; attempt++)
|
||||
{
|
||||
byte[] result = writeJpeg(image, quality);
|
||||
if (result.length <= maxBytes) return result;
|
||||
if (quality > 0.48f)
|
||||
{
|
||||
quality -= 0.08f;
|
||||
}
|
||||
else
|
||||
{
|
||||
image = scale(image, Math.max(320, (int) (image.getWidth() * 0.82)));
|
||||
}
|
||||
}
|
||||
throw new ServiceException("图片压缩后仍超过微信大小限制");
|
||||
}
|
||||
|
||||
private BufferedImage normalize(BufferedImage source, int maxWidth)
|
||||
{
|
||||
BufferedImage scaled = source.getWidth() > maxWidth ? scale(source, maxWidth) : source;
|
||||
BufferedImage rgb = new BufferedImage(scaled.getWidth(), scaled.getHeight(), BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = rgb.createGraphics();
|
||||
graphics.setColor(Color.WHITE);
|
||||
graphics.fillRect(0, 0, rgb.getWidth(), rgb.getHeight());
|
||||
graphics.drawImage(scaled, 0, 0, null);
|
||||
graphics.dispose();
|
||||
return rgb;
|
||||
}
|
||||
|
||||
private BufferedImage scale(BufferedImage source, int width)
|
||||
{
|
||||
int height = Math.max(1, (int) Math.round(source.getHeight() * (width / (double) source.getWidth())));
|
||||
BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = target.createGraphics();
|
||||
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
||||
graphics.drawImage(source, 0, 0, width, height, null);
|
||||
graphics.dispose();
|
||||
return target;
|
||||
}
|
||||
|
||||
private byte[] writeJpeg(BufferedImage image, float quality)
|
||||
{
|
||||
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpg");
|
||||
if (!writers.hasNext()) throw new ServiceException("当前 JRE 不支持 JPEG 编码");
|
||||
ImageWriter writer = writers.next();
|
||||
try (ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
ImageOutputStream imageOutput = ImageIO.createImageOutputStream(output))
|
||||
{
|
||||
writer.setOutput(imageOutput);
|
||||
ImageWriteParam param = writer.getDefaultWriteParam();
|
||||
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
|
||||
param.setCompressionQuality(quality);
|
||||
writer.write(null, new IIOImage(image, null, null), param);
|
||||
writer.dispose();
|
||||
return output.toByteArray();
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
writer.dispose();
|
||||
throw new ServiceException("JPEG 编码失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.ruoyi.common.core.redis.RedisCache;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.office.domain.TtWechatAccount;
|
||||
import com.ruoyi.office.domain.TtWechatArticle;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* 微信公众号稳定 token、素材和草稿接口客户端。
|
||||
*/
|
||||
@Component
|
||||
public class WechatMpClient
|
||||
{
|
||||
private static final String API = "https://api.weixin.qq.com";
|
||||
private static final String TOKEN_CACHE_PREFIX = "wechat:mp:token:";
|
||||
private final RedisCache redisCache;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public WechatMpClient(RedisCache redisCache)
|
||||
{
|
||||
this.redisCache = redisCache;
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(10000);
|
||||
factory.setReadTimeout(60000);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
}
|
||||
|
||||
public String getAccessToken(TtWechatAccount account)
|
||||
{
|
||||
String key = TOKEN_CACHE_PREFIX + account.getAppId();
|
||||
String cached = redisCache.getCacheObject(key);
|
||||
if (StringUtils.isNotBlank(cached)) return cached;
|
||||
|
||||
JSONObject request = new JSONObject();
|
||||
request.put("grant_type", "client_credential");
|
||||
request.put("appid", account.getAppId());
|
||||
request.put("secret", account.getAppSecret());
|
||||
request.put("force_refresh", false);
|
||||
JSONObject response = postJson(API + "/cgi-bin/stable_token", request);
|
||||
assertSuccess(response, "获取访问令牌");
|
||||
String token = response.getString("access_token");
|
||||
if (StringUtils.isBlank(token)) throw new ServiceException("微信未返回 access_token");
|
||||
int expiresIn = response.getIntValue("expires_in");
|
||||
redisCache.setCacheObject(key, token, Math.max(60, expiresIn - 300), TimeUnit.SECONDS);
|
||||
return token;
|
||||
}
|
||||
|
||||
public void clearAccessToken(TtWechatAccount account)
|
||||
{
|
||||
redisCache.deleteObject(TOKEN_CACHE_PREFIX + account.getAppId());
|
||||
}
|
||||
|
||||
public void testConnection(TtWechatAccount account)
|
||||
{
|
||||
clearAccessToken(account);
|
||||
getAccessToken(account);
|
||||
}
|
||||
|
||||
public String uploadContentImage(TtWechatAccount account, byte[] data)
|
||||
{
|
||||
JSONObject response = postMultipart("/cgi-bin/media/uploadimg", account, null, data, "article.jpg");
|
||||
assertSuccess(response, "上传正文图片");
|
||||
String url = response.getString("url");
|
||||
if (StringUtils.isBlank(url)) throw new ServiceException("微信未返回正文图片 URL");
|
||||
return url;
|
||||
}
|
||||
|
||||
public String uploadCover(TtWechatAccount account, byte[] data)
|
||||
{
|
||||
JSONObject response = postMultipart("/cgi-bin/material/add_material", account, "thumb", data, "cover.jpg");
|
||||
assertSuccess(response, "上传封面");
|
||||
String mediaId = response.getString("media_id");
|
||||
if (StringUtils.isBlank(mediaId)) throw new ServiceException("微信未返回封面 media_id");
|
||||
return mediaId;
|
||||
}
|
||||
|
||||
public String addDraft(TtWechatAccount account, TtWechatArticle article)
|
||||
{
|
||||
JSONObject item = new JSONObject();
|
||||
item.put("article_type", "news");
|
||||
item.put("title", article.getTitle());
|
||||
item.put("author", article.getAuthor());
|
||||
// digest 是可选字段,留空时不发送,避免微信把空字符串按 description 校验。
|
||||
if (StringUtils.isNotBlank(article.getDigest()))
|
||||
{
|
||||
item.put("digest", article.getDigest());
|
||||
}
|
||||
item.put("content", article.getContentHtml());
|
||||
item.put("content_source_url", article.getContentSourceUrl());
|
||||
item.put("thumb_media_id", article.getThumbMediaId());
|
||||
item.put("show_cover_pic", article.getShowCoverPic());
|
||||
item.put("need_open_comment", article.getNeedOpenComment());
|
||||
item.put("only_fans_can_comment", article.getOnlyFansCanComment());
|
||||
JSONArray articles = new JSONArray();
|
||||
articles.add(item);
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("articles", articles);
|
||||
JSONObject response = postWithToken("/cgi-bin/draft/add", account, body);
|
||||
assertSuccess(response, "创建草稿");
|
||||
String mediaId = response.getString("media_id");
|
||||
if (StringUtils.isBlank(mediaId)) throw new ServiceException("微信未返回草稿 media_id");
|
||||
return mediaId;
|
||||
}
|
||||
|
||||
public void verifyDraft(TtWechatAccount account, String mediaId)
|
||||
{
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("media_id", mediaId);
|
||||
JSONObject response = postWithToken("/cgi-bin/draft/get", account, body);
|
||||
assertSuccess(response, "读取草稿");
|
||||
if (response.getJSONArray("news_item") == null || response.getJSONArray("news_item").isEmpty())
|
||||
{
|
||||
throw new ServiceException("草稿已创建,但校验时未读取到图文内容");
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject postWithToken(String path, TtWechatAccount account, JSONObject body)
|
||||
{
|
||||
String url = UriComponentsBuilder.fromHttpUrl(API + path)
|
||||
.queryParam("access_token", getAccessToken(account)).toUriString();
|
||||
JSONObject response = postJson(url, body);
|
||||
int code = response.getIntValue("errcode");
|
||||
if (code == 40001 || code == 40014 || code == 42001)
|
||||
{
|
||||
clearAccessToken(account);
|
||||
url = UriComponentsBuilder.fromHttpUrl(API + path)
|
||||
.queryParam("access_token", getAccessToken(account)).toUriString();
|
||||
response = postJson(url, body);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private JSONObject postMultipart(String path, TtWechatAccount account, String type,
|
||||
byte[] data, String filename)
|
||||
{
|
||||
JSONObject response = doPostMultipart(path, account, type, data, filename);
|
||||
int code = response.getIntValue("errcode");
|
||||
if (code == 40001 || code == 40014 || code == 42001)
|
||||
{
|
||||
clearAccessToken(account);
|
||||
response = doPostMultipart(path, account, type, data, filename);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private JSONObject doPostMultipart(String path, TtWechatAccount account, String type,
|
||||
byte[] data, String filename)
|
||||
{
|
||||
String url = UriComponentsBuilder.fromHttpUrl(API + path)
|
||||
.queryParam("access_token", getAccessToken(account))
|
||||
.queryParamIfPresent("type", java.util.Optional.ofNullable(type))
|
||||
.toUriString();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
HttpHeaders fileHeaders = new HttpHeaders();
|
||||
fileHeaders.setContentType(MediaType.IMAGE_JPEG);
|
||||
body.add("media", new HttpEntity<>(new NamedByteArrayResource(data, filename), fileHeaders));
|
||||
try
|
||||
{
|
||||
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers), String.class);
|
||||
return JSONObject.parseObject(response.getBody());
|
||||
}
|
||||
catch (RestClientException e)
|
||||
{
|
||||
throw new ServiceException("调用微信素材接口失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject postJson(String url, JSONObject body)
|
||||
{
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
try
|
||||
{
|
||||
String text = restTemplate.postForObject(url, new HttpEntity<>(body.toJSONString(), headers), String.class);
|
||||
return JSONObject.parseObject(text);
|
||||
}
|
||||
catch (RestClientException e)
|
||||
{
|
||||
throw new ServiceException("调用微信接口失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void assertSuccess(JSONObject response, String action)
|
||||
{
|
||||
if (response == null) throw new ServiceException("微信" + action + "接口返回为空");
|
||||
Integer code = response.getInteger("errcode");
|
||||
if (code != null && code != 0)
|
||||
{
|
||||
throw new ServiceException("微信" + action + "接口错误 " + code + ":" + response.getString("errmsg"), code);
|
||||
}
|
||||
}
|
||||
|
||||
private static class NamedByteArrayResource extends ByteArrayResource
|
||||
{
|
||||
private final String filename;
|
||||
NamedByteArrayResource(byte[] byteArray, String filename)
|
||||
{
|
||||
super(byteArray);
|
||||
this.filename = filename;
|
||||
}
|
||||
@Override public String getFilename() { return filename; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 使用环境密钥加密存储公众号 AppSecret。
|
||||
*/
|
||||
@Component
|
||||
public class WechatSecretCipher
|
||||
{
|
||||
private static final String PREFIX = "gcm:";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private final String masterKey;
|
||||
|
||||
public WechatSecretCipher(@Value("${wechat.mp.secret-key:}") String masterKey)
|
||||
{
|
||||
this.masterKey = masterKey;
|
||||
}
|
||||
|
||||
public String encrypt(String plaintext)
|
||||
{
|
||||
if (StringUtils.isBlank(plaintext))
|
||||
{
|
||||
return plaintext;
|
||||
}
|
||||
ensureConfigured();
|
||||
try
|
||||
{
|
||||
byte[] iv = new byte[12];
|
||||
RANDOM.nextBytes(iv);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key(), new GCMParameterSpec(128, iv));
|
||||
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] payload = new byte[iv.length + encrypted.length];
|
||||
System.arraycopy(iv, 0, payload, 0, iv.length);
|
||||
System.arraycopy(encrypted, 0, payload, iv.length, encrypted.length);
|
||||
return PREFIX + Base64.getEncoder().encodeToString(payload);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new ServiceException("公众号 AppSecret 加密失败");
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String ciphertext)
|
||||
{
|
||||
if (StringUtils.isBlank(ciphertext))
|
||||
{
|
||||
return ciphertext;
|
||||
}
|
||||
if (!ciphertext.startsWith(PREFIX))
|
||||
{
|
||||
throw new ServiceException("公众号 AppSecret 尚未加密,请重新保存账号配置");
|
||||
}
|
||||
ensureConfigured();
|
||||
try
|
||||
{
|
||||
byte[] payload = Base64.getDecoder().decode(ciphertext.substring(PREFIX.length()));
|
||||
byte[] iv = Arrays.copyOfRange(payload, 0, 12);
|
||||
byte[] encrypted = Arrays.copyOfRange(payload, 12, payload.length);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key(), new GCMParameterSpec(128, iv));
|
||||
return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
throw e;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new ServiceException("公众号 AppSecret 解密失败,请检查 WECHAT_MP_SECRET_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
private SecretKeySpec key() throws Exception
|
||||
{
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(masterKey.getBytes(StandardCharsets.UTF_8));
|
||||
return new SecretKeySpec(digest, "AES");
|
||||
}
|
||||
|
||||
private void ensureConfigured()
|
||||
{
|
||||
if (StringUtils.isBlank(masterKey) || masterKey.length() < 16)
|
||||
{
|
||||
throw new ServiceException("请配置至少16位的 WECHAT_MP_SECRET_KEY 环境变量");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.ruoyi.office.service.impl;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.office.domain.TtWechatAccount;
|
||||
import com.ruoyi.office.mapper.TtWechatAccountMapper;
|
||||
import com.ruoyi.office.service.ITtWechatAccountService;
|
||||
import com.ruoyi.office.service.WechatSecretCipher;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class TtWechatAccountServiceImpl implements ITtWechatAccountService
|
||||
{
|
||||
private final TtWechatAccountMapper mapper;
|
||||
private final WechatSecretCipher secretCipher;
|
||||
|
||||
public TtWechatAccountServiceImpl(TtWechatAccountMapper mapper, WechatSecretCipher secretCipher)
|
||||
{
|
||||
this.mapper = mapper;
|
||||
this.secretCipher = secretCipher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TtWechatAccount selectById(Long accountId)
|
||||
{
|
||||
return mask(mapper.selectTtWechatAccountByAccountId(accountId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TtWechatAccount> selectList(TtWechatAccount account)
|
||||
{
|
||||
List<TtWechatAccount> list = mapper.selectTtWechatAccountList(account);
|
||||
list.forEach(this::mask);
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TtWechatAccount> selectEnabled()
|
||||
{
|
||||
List<TtWechatAccount> list = mapper.selectEnabledTtWechatAccounts();
|
||||
list.forEach(this::mask);
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int insert(TtWechatAccount account)
|
||||
{
|
||||
validate(account, true);
|
||||
account.setAppSecret(secretCipher.encrypt(account.getAppSecret()));
|
||||
defaults(account);
|
||||
return mapper.insertTtWechatAccount(account);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(TtWechatAccount account)
|
||||
{
|
||||
validate(account, false);
|
||||
if (StringUtils.isNotBlank(account.getAppSecret()))
|
||||
{
|
||||
account.setAppSecret(secretCipher.encrypt(account.getAppSecret()));
|
||||
}
|
||||
defaults(account);
|
||||
return mapper.updateTtWechatAccount(account);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int delete(Long[] accountIds)
|
||||
{
|
||||
return mapper.deleteTtWechatAccountByAccountIds(accountIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TtWechatAccount selectUsableWithSecret(Long accountId)
|
||||
{
|
||||
TtWechatAccount account = mapper.selectTtWechatAccountByAccountId(accountId);
|
||||
if (account == null || !"0".equals(account.getStatus()))
|
||||
{
|
||||
throw new ServiceException("公众号账号不存在或已停用");
|
||||
}
|
||||
if (StringUtils.isBlank(account.getAppSecret()))
|
||||
{
|
||||
throw new ServiceException("公众号尚未配置 AppSecret");
|
||||
}
|
||||
account.setAppSecret(secretCipher.decrypt(account.getAppSecret()));
|
||||
return account;
|
||||
}
|
||||
|
||||
private TtWechatAccount mask(TtWechatAccount account)
|
||||
{
|
||||
if (account != null)
|
||||
{
|
||||
account.setSecretConfigured(StringUtils.isNotBlank(account.getAppSecret()));
|
||||
account.setAppSecret(null);
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
private void validate(TtWechatAccount account, boolean requireSecret)
|
||||
{
|
||||
if (account == null || StringUtils.isBlank(account.getAccountName()) || StringUtils.isBlank(account.getAppId()))
|
||||
{
|
||||
throw new ServiceException("账号名称和 AppID 不能为空");
|
||||
}
|
||||
if (requireSecret && StringUtils.isBlank(account.getAppSecret()))
|
||||
{
|
||||
throw new ServiceException("AppSecret 不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
private void defaults(TtWechatAccount account)
|
||||
{
|
||||
if (StringUtils.isBlank(account.getStatus())) account.setStatus("0");
|
||||
if (account.getNeedOpenComment() == null) account.setNeedOpenComment(0);
|
||||
if (account.getOnlyFansCanComment() == null) account.setOnlyFansCanComment(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.ruoyi.office.service.impl;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.office.domain.TtCode;
|
||||
import com.ruoyi.office.domain.TtWechatAccount;
|
||||
import com.ruoyi.office.domain.TtWechatArticle;
|
||||
import com.ruoyi.office.domain.WechatDraftPreview;
|
||||
import com.ruoyi.office.domain.WechatDraftPushRequest;
|
||||
import com.ruoyi.office.mapper.TtWechatArticleMapper;
|
||||
import com.ruoyi.office.service.ITtCodeService;
|
||||
import com.ruoyi.office.service.ITtWechatAccountService;
|
||||
import com.ruoyi.office.service.IWechatDraftService;
|
||||
import com.ruoyi.office.service.WechatArticleImageService;
|
||||
import com.ruoyi.office.service.WechatCoverService;
|
||||
import com.ruoyi.office.service.WechatDraftContentService;
|
||||
import com.ruoyi.office.service.WechatMpClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class WechatDraftServiceImpl implements IWechatDraftService
|
||||
{
|
||||
private final TtWechatArticleMapper articleMapper;
|
||||
private final ITtWechatAccountService accountService;
|
||||
private final ITtCodeService codeService;
|
||||
private final WechatDraftContentService contentService;
|
||||
private final WechatArticleImageService imageService;
|
||||
private final WechatCoverService coverService;
|
||||
private final WechatMpClient mpClient;
|
||||
|
||||
public WechatDraftServiceImpl(TtWechatArticleMapper articleMapper,
|
||||
ITtWechatAccountService accountService,
|
||||
ITtCodeService codeService,
|
||||
WechatDraftContentService contentService,
|
||||
WechatArticleImageService imageService,
|
||||
WechatCoverService coverService,
|
||||
WechatMpClient mpClient)
|
||||
{
|
||||
this.articleMapper = articleMapper;
|
||||
this.accountService = accountService;
|
||||
this.codeService = codeService;
|
||||
this.contentService = contentService;
|
||||
this.imageService = imageService;
|
||||
this.coverService = coverService;
|
||||
this.mpClient = mpClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WechatDraftPreview preview(WechatDraftPushRequest request)
|
||||
{
|
||||
return contentService.preview(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TtWechatArticle push(WechatDraftPushRequest request)
|
||||
{
|
||||
if (request == null || request.getAccountId() == null)
|
||||
{
|
||||
throw new ServiceException("请选择公众号账号");
|
||||
}
|
||||
WechatDraftPreview preview = contentService.preview(request);
|
||||
TtWechatArticle existing = articleMapper.selectTtWechatArticleByRequestNo(preview.getRequestNo());
|
||||
if (existing != null)
|
||||
{
|
||||
if ("0".equals(existing.getPushStatus())) return existing;
|
||||
throw new ServiceException("该推送请求已存在,请在推送记录中重试");
|
||||
}
|
||||
|
||||
TtWechatArticle article = new TtWechatArticle();
|
||||
article.setAccountId(request.getAccountId());
|
||||
article.setCodeId(request.getCodeId());
|
||||
article.setTemplateId(request.getTemplateId());
|
||||
article.setRequestNo(preview.getRequestNo());
|
||||
article.setTitle(preview.getTitle());
|
||||
article.setAuthor(preview.getAuthor());
|
||||
article.setDigest(preview.getDigest());
|
||||
article.setContentMarkdown(preview.getContentMarkdown());
|
||||
article.setRenderedHtml(preview.getContentHtml());
|
||||
article.setContentHtml(preview.getContentHtml());
|
||||
article.setContentSourceUrl(preview.getContentSourceUrl());
|
||||
article.setThumbMediaId(request.getThumbMediaId());
|
||||
article.setShowCoverPic(request.getShowCoverPic() == null ? 0 : request.getShowCoverPic());
|
||||
article.setNeedOpenComment(preview.getNeedOpenComment());
|
||||
article.setOnlyFansCanComment(preview.getOnlyFansCanComment());
|
||||
article.setPushStatus("2");
|
||||
article.setCreateBy(username());
|
||||
article.setUpdateBy(article.getCreateBy());
|
||||
articleMapper.insertTtWechatArticle(article);
|
||||
return execute(article);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TtWechatArticle retry(Long articleId)
|
||||
{
|
||||
TtWechatArticle article = articleMapper.selectTtWechatArticleByArticleId(articleId);
|
||||
if (article == null) throw new ServiceException("推送记录不存在");
|
||||
if ("0".equals(article.getPushStatus())) return article;
|
||||
// 兼容摘要自动生成时期产生的失败记录,45004 重试时直接清空摘要。
|
||||
if (Integer.valueOf(45004).equals(article.getErrorCode())) article.setDigest("");
|
||||
article.setUpdateBy(username());
|
||||
articleMapper.updateTtWechatArticleProcessing(article);
|
||||
return execute(article);
|
||||
}
|
||||
|
||||
private TtWechatArticle execute(TtWechatArticle article)
|
||||
{
|
||||
try
|
||||
{
|
||||
TtWechatAccount account = accountService.selectUsableWithSecret(article.getAccountId());
|
||||
article.setDigest(limit(article.getDigest(), 120));
|
||||
byte[] firstImageCover = StringUtils.isBlank(article.getThumbMediaId())
|
||||
? imageService.downloadFirstImageCover(article.getRenderedHtml()) : null;
|
||||
article.setContentHtml(imageService.rewriteImages(article.getRenderedHtml(), account));
|
||||
validateContent(article.getContentHtml());
|
||||
if (StringUtils.isBlank(article.getThumbMediaId()))
|
||||
{
|
||||
byte[] cover = firstImageCover;
|
||||
if (cover == null)
|
||||
{
|
||||
TtCode code = codeService.selectTtCodeByCodeId(article.getCodeId());
|
||||
if (code == null) throw new ServiceException("正文没有图片且源码项目不存在,无法生成封面");
|
||||
cover = coverService.createCover(code);
|
||||
}
|
||||
article.setThumbMediaId(mpClient.uploadCover(account, cover));
|
||||
}
|
||||
article.setWechatMediaId(mpClient.addDraft(account, article));
|
||||
String verifyWarning = null;
|
||||
try
|
||||
{
|
||||
mpClient.verifyDraft(account, article.getWechatMediaId());
|
||||
}
|
||||
catch (RuntimeException verifyError)
|
||||
{
|
||||
// draft/add 已返回 media_id,不能因为二次校验失败而重试创建重复草稿。
|
||||
verifyWarning = "草稿已创建,但自动校验失败:" + limit(verifyError.getMessage(), 800);
|
||||
}
|
||||
article.setPushStatus("0");
|
||||
article.setErrorCode(null);
|
||||
article.setErrorMessage(verifyWarning);
|
||||
article.setPushTime(new Date());
|
||||
article.setUpdateBy(username());
|
||||
articleMapper.updateTtWechatArticleResult(article);
|
||||
article.setAccountName(account.getAccountName());
|
||||
return article;
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
article.setPushStatus("1");
|
||||
article.setErrorCode(e instanceof ServiceException && ((ServiceException) e).getCode() != null
|
||||
? ((ServiceException) e).getCode() : -1);
|
||||
article.setErrorMessage(limit(e.getMessage(), 1000));
|
||||
article.setUpdateBy(username());
|
||||
articleMapper.updateTtWechatArticleResult(article);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override public TtWechatArticle selectById(Long articleId) { return articleMapper.selectTtWechatArticleByArticleId(articleId); }
|
||||
@Override public List<TtWechatArticle> selectList(TtWechatArticle article) { return articleMapper.selectTtWechatArticleList(article); }
|
||||
|
||||
@Override
|
||||
public void testAccount(Long accountId)
|
||||
{
|
||||
mpClient.testConnection(accountService.selectUsableWithSecret(accountId));
|
||||
}
|
||||
|
||||
private String username()
|
||||
{
|
||||
try { return SecurityUtils.getUsername(); }
|
||||
catch (RuntimeException ignored) { return "system"; }
|
||||
}
|
||||
|
||||
private String limit(String value, int max)
|
||||
{
|
||||
if (value == null) return "";
|
||||
return value.length() <= max ? value : value.substring(0, max);
|
||||
}
|
||||
|
||||
private void validateContent(String html)
|
||||
{
|
||||
if (StringUtils.isBlank(html)) throw new ServiceException("公众号正文不能为空");
|
||||
if (html.length() >= 20000 || html.getBytes(StandardCharsets.UTF_8).length >= 1024 * 1024)
|
||||
{
|
||||
throw new ServiceException("排版后正文超过微信限制(2万字符或1MB)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.office.mapper.TtWechatAccountMapper">
|
||||
<resultMap type="TtWechatAccount" id="WechatAccountResult">
|
||||
<id property="accountId" column="account_id" />
|
||||
<result property="accountName" column="account_name" />
|
||||
<result property="appId" column="app_id" />
|
||||
<result property="appSecret" column="app_secret" />
|
||||
<result property="defaultAuthor" column="default_author" />
|
||||
<result property="defaultSourceUrl" column="default_source_url" />
|
||||
<result property="needOpenComment" column="need_open_comment" />
|
||||
<result property="onlyFansCanComment" column="only_fans_can_comment" />
|
||||
<result property="status" column="status" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectWechatAccountVo">
|
||||
select account_id, account_name, app_id, app_secret, default_author,
|
||||
default_source_url, need_open_comment, only_fans_can_comment,
|
||||
status, create_by, create_time, update_by, update_time, remark
|
||||
from tt_wechat_account
|
||||
</sql>
|
||||
|
||||
<select id="selectTtWechatAccountByAccountId" parameterType="Long" resultMap="WechatAccountResult">
|
||||
<include refid="selectWechatAccountVo" /> where account_id = #{accountId}
|
||||
</select>
|
||||
|
||||
<select id="selectTtWechatAccountList" parameterType="TtWechatAccount" resultMap="WechatAccountResult">
|
||||
<include refid="selectWechatAccountVo" />
|
||||
<where>
|
||||
<if test="accountName != null and accountName != ''">and account_name like concat('%', #{accountName}, '%')</if>
|
||||
<if test="appId != null and appId != ''">and app_id like concat('%', #{appId}, '%')</if>
|
||||
<if test="status != null and status != ''">and status = #{status}</if>
|
||||
</where>
|
||||
order by account_id desc
|
||||
</select>
|
||||
|
||||
<select id="selectEnabledTtWechatAccounts" resultMap="WechatAccountResult">
|
||||
<include refid="selectWechatAccountVo" /> where status = '0' order by account_id asc
|
||||
</select>
|
||||
|
||||
<insert id="insertTtWechatAccount" parameterType="TtWechatAccount" useGeneratedKeys="true" keyProperty="accountId">
|
||||
insert into tt_wechat_account
|
||||
(account_name, app_id, app_secret, default_author, default_source_url,
|
||||
need_open_comment, only_fans_can_comment, status, create_by, create_time, remark)
|
||||
values
|
||||
(#{accountName}, #{appId}, #{appSecret}, #{defaultAuthor}, #{defaultSourceUrl},
|
||||
#{needOpenComment}, #{onlyFansCanComment}, #{status}, #{createBy}, now(), #{remark})
|
||||
</insert>
|
||||
|
||||
<update id="updateTtWechatAccount" parameterType="TtWechatAccount">
|
||||
update tt_wechat_account
|
||||
<set>
|
||||
account_name = #{accountName},
|
||||
app_id = #{appId},
|
||||
<if test="appSecret != null and appSecret != ''">app_secret = #{appSecret},</if>
|
||||
default_author = #{defaultAuthor},
|
||||
default_source_url = #{defaultSourceUrl},
|
||||
need_open_comment = #{needOpenComment},
|
||||
only_fans_can_comment = #{onlyFansCanComment},
|
||||
status = #{status},
|
||||
update_by = #{updateBy},
|
||||
update_time = now(),
|
||||
remark = #{remark}
|
||||
</set>
|
||||
where account_id = #{accountId}
|
||||
</update>
|
||||
|
||||
<delete id="deleteTtWechatAccountByAccountIds" parameterType="Long">
|
||||
delete from tt_wechat_account where account_id in
|
||||
<foreach item="accountId" collection="array" open="(" separator="," close=")">#{accountId}</foreach>
|
||||
</delete>
|
||||
</mapper>
|
||||
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.office.mapper.TtWechatArticleMapper">
|
||||
<resultMap type="TtWechatArticle" id="WechatArticleResult">
|
||||
<id property="articleId" column="article_id" />
|
||||
<result property="accountId" column="account_id" />
|
||||
<result property="accountName" column="account_name" />
|
||||
<result property="codeId" column="code_id" />
|
||||
<result property="templateId" column="template_id" />
|
||||
<result property="requestNo" column="request_no" />
|
||||
<result property="title" column="title" />
|
||||
<result property="author" column="author" />
|
||||
<result property="digest" column="digest" />
|
||||
<result property="contentMarkdown" column="content_markdown" />
|
||||
<result property="renderedHtml" column="rendered_html" />
|
||||
<result property="contentHtml" column="content_html" />
|
||||
<result property="contentSourceUrl" column="content_source_url" />
|
||||
<result property="thumbMediaId" column="thumb_media_id" />
|
||||
<result property="showCoverPic" column="show_cover_pic" />
|
||||
<result property="needOpenComment" column="need_open_comment" />
|
||||
<result property="onlyFansCanComment" column="only_fans_can_comment" />
|
||||
<result property="wechatMediaId" column="wechat_media_id" />
|
||||
<result property="pushStatus" column="push_status" />
|
||||
<result property="errorCode" column="error_code" />
|
||||
<result property="errorMessage" column="error_message" />
|
||||
<result property="retryCount" column="retry_count" />
|
||||
<result property="pushTime" column="push_time" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectWechatArticleVo">
|
||||
select a.article_id, a.account_id, w.account_name, a.code_id, a.template_id,
|
||||
a.request_no, a.title, a.author, a.digest, a.content_markdown,
|
||||
a.rendered_html, a.content_html, a.content_source_url, a.thumb_media_id,
|
||||
a.show_cover_pic, a.need_open_comment, a.only_fans_can_comment,
|
||||
a.wechat_media_id, a.push_status, a.error_code, a.error_message,
|
||||
a.retry_count, a.push_time, a.create_by, a.create_time, a.update_by, a.update_time
|
||||
from tt_wechat_article a
|
||||
left join tt_wechat_account w on w.account_id = a.account_id
|
||||
</sql>
|
||||
|
||||
<select id="selectTtWechatArticleByArticleId" parameterType="Long" resultMap="WechatArticleResult">
|
||||
<include refid="selectWechatArticleVo" /> where a.article_id = #{articleId}
|
||||
</select>
|
||||
|
||||
<select id="selectTtWechatArticleByRequestNo" parameterType="String" resultMap="WechatArticleResult">
|
||||
<include refid="selectWechatArticleVo" /> where a.request_no = #{requestNo} limit 1
|
||||
</select>
|
||||
|
||||
<select id="selectTtWechatArticleList" parameterType="TtWechatArticle" resultMap="WechatArticleResult">
|
||||
<include refid="selectWechatArticleVo" />
|
||||
<where>
|
||||
<if test="accountId != null">and a.account_id = #{accountId}</if>
|
||||
<if test="title != null and title != ''">and a.title like concat('%', #{title}, '%')</if>
|
||||
<if test="pushStatus != null and pushStatus != ''">and a.push_status = #{pushStatus}</if>
|
||||
</where>
|
||||
order by a.article_id desc
|
||||
</select>
|
||||
|
||||
<insert id="insertTtWechatArticle" parameterType="TtWechatArticle" useGeneratedKeys="true" keyProperty="articleId">
|
||||
insert into tt_wechat_article
|
||||
(account_id, code_id, template_id, request_no, title, author, digest,
|
||||
content_markdown, rendered_html, content_html, content_source_url,
|
||||
thumb_media_id, show_cover_pic, need_open_comment, only_fans_can_comment,
|
||||
push_status, retry_count, create_by, create_time, update_by, update_time)
|
||||
values
|
||||
(#{accountId}, #{codeId}, #{templateId}, #{requestNo}, #{title}, #{author}, #{digest},
|
||||
#{contentMarkdown}, #{renderedHtml}, #{contentHtml}, #{contentSourceUrl},
|
||||
#{thumbMediaId}, #{showCoverPic}, #{needOpenComment}, #{onlyFansCanComment},
|
||||
#{pushStatus}, 0, #{createBy}, now(), #{updateBy}, now())
|
||||
</insert>
|
||||
|
||||
<update id="updateTtWechatArticleResult" parameterType="TtWechatArticle">
|
||||
update tt_wechat_article
|
||||
set digest = #{digest}, content_html = #{contentHtml}, thumb_media_id = #{thumbMediaId},
|
||||
wechat_media_id = #{wechatMediaId}, push_status = #{pushStatus},
|
||||
error_code = #{errorCode}, error_message = #{errorMessage},
|
||||
push_time = #{pushTime}, update_by = #{updateBy}, update_time = now()
|
||||
where article_id = #{articleId}
|
||||
</update>
|
||||
|
||||
<update id="updateTtWechatArticleProcessing" parameterType="TtWechatArticle">
|
||||
update tt_wechat_article
|
||||
set push_status = '2', error_code = null, error_message = null,
|
||||
retry_count = retry_count + 1, update_by = #{updateBy}, update_time = now()
|
||||
where article_id = #{articleId}
|
||||
</update>
|
||||
</mapper>
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
public class WechatArticleImageServiceTest
|
||||
{
|
||||
@Test
|
||||
public void downloadsFirstContentImageAsCover()
|
||||
{
|
||||
RecordingImageProcessor processor = new RecordingImageProcessor();
|
||||
WechatArticleImageService service = new WechatArticleImageService(processor, null);
|
||||
|
||||
byte[] result = service.downloadFirstImageCover(
|
||||
"<p>正文</p><img src=\"https://img.example.com/first.png\">"
|
||||
+ "<img src=\"https://img.example.com/second.png\">");
|
||||
|
||||
Assert.assertEquals("https://img.example.com/first.png", processor.requestedUrl);
|
||||
Assert.assertEquals("cover", new String(result, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void returnsNullWhenContentHasNoImage()
|
||||
{
|
||||
RecordingImageProcessor processor = new RecordingImageProcessor();
|
||||
WechatArticleImageService service = new WechatArticleImageService(processor, null);
|
||||
|
||||
Assert.assertNull(service.downloadFirstImageCover("<p>只有正文</p>"));
|
||||
Assert.assertNull(processor.requestedUrl);
|
||||
}
|
||||
|
||||
private static class RecordingImageProcessor extends WechatImageProcessor
|
||||
{
|
||||
private String requestedUrl;
|
||||
|
||||
@Override
|
||||
public byte[] downloadCoverImage(String sourceUrl)
|
||||
{
|
||||
requestedUrl = sourceUrl;
|
||||
return "cover".getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
public class WechatArticleRendererTest
|
||||
{
|
||||
private final WechatArticleRenderer renderer = new WechatArticleRenderer();
|
||||
|
||||
@Test
|
||||
public void rendersWechatSafeInlineStyles()
|
||||
{
|
||||
String html = renderer.render("## 项目描述\n\n正文 **重点**\n\n", "#123456");
|
||||
|
||||
Assert.assertTrue(html.contains("background:#123456"));
|
||||
Assert.assertTrue(html.contains("color:#123456"));
|
||||
Assert.assertTrue(html.contains("https://img.example.com/a.png"));
|
||||
Assert.assertFalse(html.contains("<style"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void removesUnsafeHtml()
|
||||
{
|
||||
String html = renderer.render("<script>alert(1)</script><img src=\"https://img.example.com/a.png\" onerror=\"alert(2)\">", "#07c160");
|
||||
|
||||
Assert.assertFalse(html.contains("script"));
|
||||
Assert.assertFalse(html.contains("onerror"));
|
||||
Assert.assertTrue(html.contains("https://img.example.com/a.png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preservesSingleMarkdownLineBreaks()
|
||||
{
|
||||
String html = renderer.render(
|
||||
"顾客:注册登录\n管理员:后台管理\n\n**前端**:Vue 3\n**后端**:Java 17\n**数据库**:MySQL 8",
|
||||
"#07c160");
|
||||
|
||||
Assert.assertTrue(html.contains("顾客:注册登录<br>\n管理员:后台管理"));
|
||||
Assert.assertTrue(html.contains("Vue 3<br>\n<strong"));
|
||||
Assert.assertTrue(html.contains("Java 17<br>\n<strong"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rendersGraceThemeOverridesAsInlineStyles()
|
||||
{
|
||||
String html = renderer.render(
|
||||
"## 优雅标题\n\n> 引用内容\n\n",
|
||||
"#123456", "grace");
|
||||
|
||||
Assert.assertTrue(html.contains("border-radius:8px"));
|
||||
Assert.assertTrue(html.contains("box-shadow:0 4px 6px rgba(0,0,0,0.1)"));
|
||||
Assert.assertTrue(html.contains("box-shadow:0 4px 8px rgba(0,0,0,0.1)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rendersSimpleThemeAndFallsBackForUnknownTheme()
|
||||
{
|
||||
String simple = renderer.render("## 简洁标题\n\n### 三级标题", "#009874", "simple");
|
||||
String fallback = renderer.render("## 默认标题", "#009874", "unknown");
|
||||
|
||||
Assert.assertTrue(simple.contains("border-radius:8px 24px 8px 24px"));
|
||||
Assert.assertTrue(simple.contains("background:rgba(0,152,116,0.08)"));
|
||||
Assert.assertFalse(fallback.contains("border-radius:8px 24px 8px 24px"));
|
||||
Assert.assertTrue(fallback.contains("background:#009874"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void appliesSelectedFontSizeAndFallsBackForInvalidValue()
|
||||
{
|
||||
String larger = renderer.render("## 优雅标题\n\n正文", "#123456", "grace", 18);
|
||||
String fallback = renderer.render("## 默认标题\n\n正文", "#123456", "default", 99);
|
||||
|
||||
Assert.assertTrue(larger.contains("font-size:18px"));
|
||||
Assert.assertTrue(larger.contains("font-size:23.4px"));
|
||||
Assert.assertTrue(fallback.contains("font-size:16px"));
|
||||
Assert.assertTrue(fallback.contains("font-size:19.2px"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.net.URI;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
public class WechatImageProcessorTest
|
||||
{
|
||||
@Test
|
||||
public void compressesCoverBelowWechatThumbLimit()
|
||||
{
|
||||
BufferedImage image = new BufferedImage(1200, 675, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = image.createGraphics();
|
||||
graphics.setColor(new Color(23, 130, 88));
|
||||
graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
|
||||
graphics.dispose();
|
||||
|
||||
byte[] result = new WechatImageProcessor().toCoverJpeg(image);
|
||||
|
||||
Assert.assertTrue(result.length > 0);
|
||||
Assert.assertTrue(result.length <= 60 * 1024);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void encodesUnicodeSpacesAndSpecialCharactersInImageUrl() throws Exception
|
||||
{
|
||||
URI result = new WechatImageProcessor().toRequestUri(
|
||||
"https://img.yidaima.cn/1_后台 原料库存[列表]-abc.png?name=中文 图片");
|
||||
|
||||
Assert.assertEquals(
|
||||
"https://img.yidaima.cn/1_%E5%90%8E%E5%8F%B0%20%E5%8E%9F%E6%96%99%E5%BA%93%E5%AD%98%5B%E5%88%97%E8%A1%A8%5D-abc.png"
|
||||
+ "?name=%E4%B8%AD%E6%96%87%20%E5%9B%BE%E7%89%87",
|
||||
result.toASCIIString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void keepsExistingPercentEncodingInImageUrl() throws Exception
|
||||
{
|
||||
URI result = new WechatImageProcessor().toRequestUri(
|
||||
"https://img.yidaima.cn/%E5%90%8E%E5%8F%B0%20image.png");
|
||||
|
||||
Assert.assertEquals("https://img.yidaima.cn/%E5%90%8E%E5%8F%B0%20image.png",
|
||||
result.toASCIIString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void skipsTlsValidationOnlyForExactConfiguredImageHost()
|
||||
{
|
||||
WechatImageProcessor processor = new WechatImageProcessor();
|
||||
|
||||
Assert.assertTrue(processor.shouldSkipTlsValidation(URI.create("https://img.yidaima.cn/a.png")));
|
||||
Assert.assertFalse(processor.shouldSkipTlsValidation(URI.create("http://img.yidaima.cn/a.png")));
|
||||
Assert.assertFalse(processor.shouldSkipTlsValidation(URI.create("https://cdn.img.yidaima.cn/a.png")));
|
||||
Assert.assertFalse(processor.shouldSkipTlsValidation(URI.create("https://img.yidaima.cn.evil.test/a.png")));
|
||||
Assert.assertFalse(processor.shouldSkipTlsValidation(URI.create("https://api.weixin.qq.com/a.png")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ruoyi.office.service;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
public class WechatSecretCipherTest
|
||||
{
|
||||
@Test
|
||||
public void encryptsAndDecryptsSecret()
|
||||
{
|
||||
WechatSecretCipher cipher = new WechatSecretCipher("test-master-key-1234567890");
|
||||
String encrypted = cipher.encrypt("wechat-secret");
|
||||
|
||||
Assert.assertTrue(encrypted.startsWith("gcm:"));
|
||||
Assert.assertNotEquals("wechat-secret", encrypted);
|
||||
Assert.assertEquals("wechat-secret", cipher.decrypt(encrypted));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user