Add WeChat draft publishing

This commit is contained in:
王鹏
2026-09-18 09:30:59 +08:00
parent c9237cd480
commit 4ed8743934
30 changed files with 2536 additions and 2 deletions

View File

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

View File

@@ -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("连接成功");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,4 +14,6 @@ public interface TtWechatArticleMapper
int insertTtWechatArticle(TtWechatArticle article);
int updateTtWechatArticleResult(TtWechatArticle article);
int updateTtWechatArticleProcessing(TtWechatArticle article);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 环境变量");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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![](https://img.example.com/a.png)", "#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![](https://img.example.com/a.png)",
"#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"));
}
}

View File

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

View File

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