From 4ed8743934d59432c8334ffbd4c19f21623bc3cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Fri, 18 Sep 2026 09:30:59 +0800 Subject: [PATCH] Add WeChat draft publishing --- docs/wechat-draft.md | 27 ++ ruoyi-office/pom.xml | 22 ++ .../controller/TtWechatAccountController.java | 92 +++++ .../controller/TtWechatDraftController.java | 60 +++ .../ruoyi/office/domain/TtWechatAccount.java | 17 + .../ruoyi/office/domain/TtWechatArticle.java | 15 + .../office/domain/WechatDraftPreview.java | 36 ++ .../office/domain/WechatDraftPushRequest.java | 22 +- .../office/mapper/TtWechatArticleMapper.java | 2 + .../service/ITtWechatAccountService.java | 15 + .../office/service/IWechatDraftService.java | 16 + .../service/WechatArticleImageService.java | 53 +++ .../office/service/WechatArticleRenderer.java | 188 ++++++++++ .../office/service/WechatCoverService.java | 53 +++ .../service/WechatDraftContentService.java | 90 +++++ .../office/service/WechatImageProcessor.java | 344 ++++++++++++++++++ .../ruoyi/office/service/WechatMpClient.java | 223 ++++++++++++ .../office/service/WechatSecretCipher.java | 100 +++++ .../impl/TtWechatAccountServiceImpl.java | 117 ++++++ .../service/impl/WechatDraftServiceImpl.java | 191 ++++++++++ .../mapper/office/TtWechatAccountMapper.xml | 77 ++++ .../mapper/office/TtWechatArticleMapper.xml | 91 +++++ .../WechatArticleImageServiceTest.java | 44 +++ .../service/WechatArticleRendererTest.java | 78 ++++ .../service/WechatImageProcessorTest.java | 60 +++ .../service/WechatSecretCipherTest.java | 18 + ruoyi-ui/src/api/office/wechat.js | 45 +++ ruoyi-ui/src/views/office/code/index.vue | 241 +++++++++++- ruoyi-ui/src/views/office/wechat/index.vue | 113 ++++++ sql/wechat_draft.sql | 88 +++++ 30 files changed, 2536 insertions(+), 2 deletions(-) create mode 100644 docs/wechat-draft.md create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/controller/TtWechatAccountController.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/controller/TtWechatDraftController.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/domain/WechatDraftPreview.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/ITtWechatAccountService.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/IWechatDraftService.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/WechatArticleImageService.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/WechatArticleRenderer.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/WechatCoverService.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/WechatDraftContentService.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/WechatImageProcessor.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/WechatMpClient.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/WechatSecretCipher.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtWechatAccountServiceImpl.java create mode 100644 ruoyi-office/src/main/java/com/ruoyi/office/service/impl/WechatDraftServiceImpl.java create mode 100644 ruoyi-office/src/main/resources/mapper/office/TtWechatAccountMapper.xml create mode 100644 ruoyi-office/src/main/resources/mapper/office/TtWechatArticleMapper.xml create mode 100644 ruoyi-office/src/test/java/com/ruoyi/office/service/WechatArticleImageServiceTest.java create mode 100644 ruoyi-office/src/test/java/com/ruoyi/office/service/WechatArticleRendererTest.java create mode 100644 ruoyi-office/src/test/java/com/ruoyi/office/service/WechatImageProcessorTest.java create mode 100644 ruoyi-office/src/test/java/com/ruoyi/office/service/WechatSecretCipherTest.java create mode 100644 ruoyi-ui/src/api/office/wechat.js create mode 100644 ruoyi-ui/src/views/office/wechat/index.vue create mode 100644 sql/wechat_draft.sql diff --git a/docs/wechat-draft.md b/docs/wechat-draft.md new file mode 100644 index 0000000..208155a --- /dev/null +++ b/docs/wechat-draft.md @@ -0,0 +1,27 @@ +# 公众号草稿箱集成部署 + +## 部署 + +1. 执行 `sql/wechat_draft.sql`。 +2. 为后端设置至少 16 位的环境变量 `WECHAT_MP_SECRET_KEY`。该值用于 AES-GCM 加密 AppSecret,上线后不要随意更换。 +3. 将后端服务器的固定出口 IP 添加到微信公众平台 IP 白名单。 +4. 在角色管理中授予“公众号草稿”菜单及相关按钮权限。 +5. 打开“公众号草稿 → 公众号账号”,录入 AppID/AppSecret 后先执行“测试”。 + +Spring Boot 也支持使用配置项 `wechat.mp.secret-key`,生产环境建议始终使用环境变量。 + +## 使用 + +1. 进入“源码管理”,在项目行点击“公众号草稿”。 +2. 选择公众号、文案模板、排版主题(经典/优雅/简洁)、字号(14~18px)和主题色,修改 Markdown 后点击“刷新预览”。 +3. 确认后点击“推送到草稿箱”。后端会下载正文图片,转存到微信图床,生成封面并创建草稿。 +4. 可在“公众号草稿 → 草稿推送记录”查看 MediaID、错误信息或重试失败任务。 + +内置“经典、优雅、简洁”三套排版主题,以及“更小、稍小、推荐、稍大、更大”五档字号。主题结构与视觉规则参考 `doocs/md`,后端会转换为微信公众号可用的内联样式 HTML。 + +## 限制 + +- 正文图片会统一转换为 JPEG 并压缩至 1MB 以内。 +- 封面优先使用正文第一张图片,并压缩后上传为永久缩略图素材;正文没有图片时使用现有 `CoverGenerator` 生成项目文字封面。 +- 当 `draft/add` 已返回 MediaID,但 `draft/get` 二次校验失败时,记录仍标记为成功并保留警告,避免重试产生重复草稿。 +- 系统只推送到草稿箱,不会自动发表。 diff --git a/ruoyi-office/pom.xml b/ruoyi-office/pom.xml index b1f67a0..97e3052 100644 --- a/ruoyi-office/pom.xml +++ b/ruoyi-office/pom.xml @@ -28,6 +28,28 @@ ruoyi-system + + + org.commonmark + commonmark + 0.21.0 + + + org.commonmark + commonmark-ext-gfm-tables + 0.21.0 + + + org.commonmark + commonmark-ext-gfm-strikethrough + 0.21.0 + + + org.jsoup + jsoup + 1.16.2 + + org.springframework.boot spring-boot-starter-test diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtWechatAccountController.java b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtWechatAccountController.java new file mode 100644 index 0000000..ba3df56 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtWechatAccountController.java @@ -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 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("连接成功"); + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtWechatDraftController.java b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtWechatDraftController.java new file mode 100644 index 0000000..650c3ae --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/controller/TtWechatDraftController.java @@ -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)); + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatAccount.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatAccount.java index 8744956..e135089 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatAccount.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatAccount.java @@ -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; } } diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatArticle.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatArticle.java index a2f8d51..6759e1a 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatArticle.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/TtWechatArticle.java @@ -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; } } diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/WechatDraftPreview.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WechatDraftPreview.java new file mode 100644 index 0000000..746e302 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WechatDraftPreview.java @@ -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; } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/domain/WechatDraftPushRequest.java b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WechatDraftPushRequest.java index 45b26b9..ac2f204 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/domain/WechatDraftPushRequest.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/domain/WechatDraftPushRequest.java @@ -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; } diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtWechatArticleMapper.java b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtWechatArticleMapper.java index 923b119..e0b10b2 100644 --- a/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtWechatArticleMapper.java +++ b/ruoyi-office/src/main/java/com/ruoyi/office/mapper/TtWechatArticleMapper.java @@ -14,4 +14,6 @@ public interface TtWechatArticleMapper int insertTtWechatArticle(TtWechatArticle article); int updateTtWechatArticleResult(TtWechatArticle article); + + int updateTtWechatArticleProcessing(TtWechatArticle article); } diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtWechatAccountService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtWechatAccountService.java new file mode 100644 index 0000000..e4e4195 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/ITtWechatAccountService.java @@ -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 selectList(TtWechatAccount account); + List selectEnabled(); + int insert(TtWechatAccount account); + int update(TtWechatAccount account); + int delete(Long[] accountIds); + TtWechatAccount selectUsableWithSecret(Long accountId); +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/IWechatDraftService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/IWechatDraftService.java new file mode 100644 index 0000000..0fc6588 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/IWechatDraftService.java @@ -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 selectList(TtWechatArticle article); + void testAccount(Long accountId); +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatArticleImageService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatArticleImageService.java new file mode 100644 index 0000000..010961a --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatArticleImageService.java @@ -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/")); + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatArticleRenderer.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatArticleRenderer.java new file mode 100644 index 0000000..63577f7 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatArticleRenderer.java @@ -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 extensions = Arrays.asList( + TablesExtension.create(), StrikethroughExtension.create()); + this.parser = Parser.builder().extensions(extensions).build(); + // 公众号排版按编辑器中的单换行展示,避免 CommonMark 软换行被浏览器折叠为空格。 + this.renderer = HtmlRenderer.builder().extensions(extensions).softbreak("
\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 "
" + + inner + "
"; + } + + 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 elements, String style) + { + for (Element element : elements) + { + element.attr("style", style); + } + } + + private void appendStyle(Iterable elements, String style) + { + for (Element element : elements) + { + String current = element.attr("style"); + element.attr("style", current + (current.endsWith(";") || current.isEmpty() ? "" : ";") + style); + } + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatCoverService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatCoverService.java new file mode 100644 index 0000000..9a23dde --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatCoverService.java @@ -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 ""; + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatDraftContentService.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatDraftContentService.java new file mode 100644 index 0000000..d48a350 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatDraftContentService.java @@ -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); + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatImageProcessor.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatImageProcessor.java new file mode 100644 index 0000000..d4ed9f4 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatImageProcessor.java @@ -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 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()); + } + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatMpClient.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatMpClient.java new file mode 100644 index 0000000..c2e8daf --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatMpClient.java @@ -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 body = new LinkedMultiValueMap<>(); + HttpHeaders fileHeaders = new HttpHeaders(); + fileHeaders.setContentType(MediaType.IMAGE_JPEG); + body.add("media", new HttpEntity<>(new NamedByteArrayResource(data, filename), fileHeaders)); + try + { + ResponseEntity 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; } + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatSecretCipher.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatSecretCipher.java new file mode 100644 index 0000000..9ce356e --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/WechatSecretCipher.java @@ -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 环境变量"); + } + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtWechatAccountServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtWechatAccountServiceImpl.java new file mode 100644 index 0000000..da3edef --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/TtWechatAccountServiceImpl.java @@ -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 selectList(TtWechatAccount account) + { + List list = mapper.selectTtWechatAccountList(account); + list.forEach(this::mask); + return list; + } + + @Override + public List selectEnabled() + { + List 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); + } +} diff --git a/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/WechatDraftServiceImpl.java b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/WechatDraftServiceImpl.java new file mode 100644 index 0000000..ea43ce8 --- /dev/null +++ b/ruoyi-office/src/main/java/com/ruoyi/office/service/impl/WechatDraftServiceImpl.java @@ -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 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)"); + } + } +} diff --git a/ruoyi-office/src/main/resources/mapper/office/TtWechatAccountMapper.xml b/ruoyi-office/src/main/resources/mapper/office/TtWechatAccountMapper.xml new file mode 100644 index 0000000..dacbca6 --- /dev/null +++ b/ruoyi-office/src/main/resources/mapper/office/TtWechatAccountMapper.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + 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}) + + + + update tt_wechat_account + + account_name = #{accountName}, + app_id = #{appId}, + app_secret = #{appSecret}, + 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} + + where account_id = #{accountId} + + + + delete from tt_wechat_account where account_id in + #{accountId} + + diff --git a/ruoyi-office/src/main/resources/mapper/office/TtWechatArticleMapper.xml b/ruoyi-office/src/main/resources/mapper/office/TtWechatArticleMapper.xml new file mode 100644 index 0000000..7373468 --- /dev/null +++ b/ruoyi-office/src/main/resources/mapper/office/TtWechatArticleMapper.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + 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()) + + + + 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 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} + + diff --git a/ruoyi-office/src/test/java/com/ruoyi/office/service/WechatArticleImageServiceTest.java b/ruoyi-office/src/test/java/com/ruoyi/office/service/WechatArticleImageServiceTest.java new file mode 100644 index 0000000..e7c1f24 --- /dev/null +++ b/ruoyi-office/src/test/java/com/ruoyi/office/service/WechatArticleImageServiceTest.java @@ -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( + "

正文

" + + ""); + + 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("

只有正文

")); + 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); + } + } +} diff --git a/ruoyi-office/src/test/java/com/ruoyi/office/service/WechatArticleRendererTest.java b/ruoyi-office/src/test/java/com/ruoyi/office/service/WechatArticleRendererTest.java new file mode 100644 index 0000000..bdf5a1a --- /dev/null +++ b/ruoyi-office/src/test/java/com/ruoyi/office/service/WechatArticleRendererTest.java @@ -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("alert(1)", "#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("顾客:注册登录
\n管理员:后台管理")); + Assert.assertTrue(html.contains("Vue 3
\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")); + } +} diff --git a/ruoyi-office/src/test/java/com/ruoyi/office/service/WechatImageProcessorTest.java b/ruoyi-office/src/test/java/com/ruoyi/office/service/WechatImageProcessorTest.java new file mode 100644 index 0000000..9b51b44 --- /dev/null +++ b/ruoyi-office/src/test/java/com/ruoyi/office/service/WechatImageProcessorTest.java @@ -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"))); + } +} diff --git a/ruoyi-office/src/test/java/com/ruoyi/office/service/WechatSecretCipherTest.java b/ruoyi-office/src/test/java/com/ruoyi/office/service/WechatSecretCipherTest.java new file mode 100644 index 0000000..ffbbb82 --- /dev/null +++ b/ruoyi-office/src/test/java/com/ruoyi/office/service/WechatSecretCipherTest.java @@ -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)); + } +} diff --git a/ruoyi-ui/src/api/office/wechat.js b/ruoyi-ui/src/api/office/wechat.js new file mode 100644 index 0000000..18b9f47 --- /dev/null +++ b/ruoyi-ui/src/api/office/wechat.js @@ -0,0 +1,45 @@ +import request from '@/utils/request' + +export function listWechatAccounts(query) { + return request({ url: '/office/wechat/account/list', method: 'get', params: query }) +} + +export function listEnabledWechatAccounts() { + return request({ url: '/office/wechat/account/enabled', method: 'get' }) +} + +export function getWechatAccount(accountId) { + return request({ url: '/office/wechat/account/' + accountId, method: 'get' }) +} + +export function addWechatAccount(data) { + return request({ url: '/office/wechat/account', method: 'post', data }) +} + +export function updateWechatAccount(data) { + return request({ url: '/office/wechat/account', method: 'put', data }) +} + +export function deleteWechatAccount(accountIds) { + return request({ url: '/office/wechat/account/' + accountIds, method: 'delete' }) +} + +export function testWechatAccount(accountId) { + return request({ url: '/office/wechat/account/' + accountId + '/test', method: 'post', timeout: 30000 }) +} + +export function previewWechatDraft(data) { + return request({ url: '/office/wechat/draft/preview', method: 'post', data, timeout: 30000 }) +} + +export function pushWechatDraft(data) { + return request({ url: '/office/wechat/draft', method: 'post', data, timeout: 180000 }) +} + +export function listWechatDrafts(query) { + return request({ url: '/office/wechat/draft/list', method: 'get', params: query }) +} + +export function retryWechatDraft(articleId) { + return request({ url: '/office/wechat/draft/' + articleId + '/retry', method: 'post', timeout: 180000 }) +} diff --git a/ruoyi-ui/src/views/office/code/index.vue b/ruoyi-ui/src/views/office/code/index.vue index 0f3439a..ca5e5c9 100644 --- a/ruoyi-ui/src/views/office/code/index.vue +++ b/ruoyi-ui/src/views/office/code/index.vue @@ -146,6 +146,14 @@ v-hasPermi="['office:code:edit']" >同步网站 + 公众号草稿 + + + +
+ + + + + + + + + + + + + + + + + + + + 经典 + 优雅 + 简洁 + + + + + + + + + + + + + + + + 更小 + 稍小 + 推荐 + 稍大 + 更大 + + + + + + + + + + + +
+
+
Markdown
+ +
+
+
公众号预览
+
+
+
+
+ + + @@ -404,6 +492,7 @@ import {html2Text} from "../../../utils"; import {getToken} from "@/utils/auth"; import {blobValidate} from "@/utils/ruoyi"; + import {listEnabledWechatAccounts, previewWechatDraft, pushWechatDraft} from "@/api/office/wechat"; export default { name: "Code", @@ -476,12 +565,19 @@ // 正在同步网站的源码ID,防止重复点击 syncingCodeId: null, // 是否正在打包下载项目截图 - downloadingScreenshots: false + downloadingScreenshots: false, + // 公众号草稿 + wechatAccounts: [], + wechatDraftOpen: false, + wechatDraftLoading: false, + wechatDraftPushing: false, + wechatDraftForm: {} }; }, created() { this.getList(); this.loadCopyTemplates(); + this.loadWechatAccounts(); }, beforeDestroy() { if (this.copywritingCopyTimer) { @@ -884,6 +980,87 @@ this.syncingCodeId = null; }); }, + loadWechatAccounts() { + listEnabledWechatAccounts().then(response => { + this.wechatAccounts = response.data || []; + }).catch(() => { + this.wechatAccounts = []; + }); + }, + handleWechatDraft(row) { + if (!this.wechatAccounts.length) { + this.$modal.msgError('请先在“公众号草稿”菜单配置并启用公众号账号'); + return; + } + const preferredTemplate = this.copyTemplates.find(tpl => (tpl.templateGroup || '').indexOf('公众号') >= 0) + || this.copyTemplates.find(tpl => tpl.templateName === '南音') + || this.copyTemplates[0]; + if (!preferredTemplate) { + this.$modal.msgError('请先配置启用的源码文案模板'); + return; + } + this.wechatDraftForm = { + accountId: this.wechatAccounts[0].accountId, + codeId: row.codeId, + templateId: preferredTemplate.templateId, + requestNo: '', + title: '', + author: '', + digest: '', + contentMarkdown: '', + contentHtml: '', + contentSourceUrl: '', + themeName: 'default', + themeColor: '#07c160', + fontSize: 16, + showCoverPic: 0, + needOpenComment: null, + onlyFansCanComment: null + }; + this.wechatDraftOpen = true; + this.refreshWechatPreview(); + }, + refreshWechatPreview() { + if (!this.wechatDraftForm.codeId || !this.wechatDraftForm.templateId) return; + this.wechatDraftLoading = true; + previewWechatDraft(this.wechatDraftForm).then(response => { + const preview = response.data || {}; + this.wechatDraftForm = Object.assign({}, this.wechatDraftForm, preview); + }).finally(() => { + this.wechatDraftLoading = false; + }); + }, + handleWechatAccountChange() { + this.wechatDraftForm.author = ''; + this.wechatDraftForm.contentSourceUrl = ''; + this.wechatDraftForm.needOpenComment = null; + this.wechatDraftForm.onlyFansCanComment = null; + this.refreshWechatPreview(); + }, + handleWechatTemplateChange() { + this.wechatDraftForm.title = ''; + this.wechatDraftForm.digest = ''; + this.wechatDraftForm.contentMarkdown = ''; + this.wechatDraftForm.contentHtml = ''; + this.refreshWechatPreview(); + }, + submitWechatDraft() { + if (!this.wechatDraftForm.accountId) { + this.$modal.msgError('请选择公众号账号'); + return; + } + if (!this.wechatDraftForm.title || !this.wechatDraftForm.contentMarkdown) { + this.$modal.msgError('标题和正文不能为空'); + return; + } + this.wechatDraftPushing = true; + pushWechatDraft(this.wechatDraftForm).then(response => { + this.$modal.msgSuccess(response.msg || '已推送到公众号草稿箱'); + this.wechatDraftOpen = false; + }).finally(() => { + this.wechatDraftPushing = false; + }); + }, /** 提交按钮 */ submitForm() { this.$refs["form"].validate(valid => { @@ -1094,6 +1271,53 @@ color: #606266; } + .wechat-draft-editor { + display: flex; + height: 56vh; + min-height: 460px; + border: 1px solid #dcdfe6; + border-radius: 4px; + overflow: hidden; + } + + .wechat-draft-pane { + display: flex; + width: 50%; + min-width: 0; + flex-direction: column; + } + + .wechat-draft-pane + .wechat-draft-pane { + border-left: 1px solid #dcdfe6; + } + + .wechat-draft-pane-title { + padding: 10px 14px; + border-bottom: 1px solid #ebeef5; + background: #f5f7fa; + color: #303133; + font-weight: 600; + } + + .wechat-markdown-input, + .wechat-markdown-input ::v-deep .el-textarea__inner { + height: 100%; + } + + .wechat-markdown-input ::v-deep .el-textarea__inner { + border: 0; + border-radius: 0; + font-family: Consolas, "Courier New", monospace; + line-height: 1.65; + } + + .wechat-html-preview { + flex: 1; + padding: 18px 28px; + overflow-y: auto; + background: #fff; + } + @media (max-width: 768px) { .copywriting-layout { height: 68vh; @@ -1117,5 +1341,20 @@ gap: 12px; flex-direction: column; } + + .wechat-draft-editor { + height: auto; + flex-direction: column; + } + + .wechat-draft-pane { + width: 100%; + min-height: 360px; + } + + .wechat-draft-pane + .wechat-draft-pane { + border-top: 1px solid #dcdfe6; + border-left: 0; + } } diff --git a/ruoyi-ui/src/views/office/wechat/index.vue b/ruoyi-ui/src/views/office/wechat/index.vue new file mode 100644 index 0000000..29a23df --- /dev/null +++ b/ruoyi-ui/src/views/office/wechat/index.vue @@ -0,0 +1,113 @@ + + + diff --git a/sql/wechat_draft.sql b/sql/wechat_draft.sql new file mode 100644 index 0000000..3726743 --- /dev/null +++ b/sql/wechat_draft.sql @@ -0,0 +1,88 @@ +-- 微信公众号草稿箱集成。 +-- 部署前请为后端设置 WECHAT_MP_SECRET_KEY(至少16位),并将服务器出口 IP 加入公众号白名单。 + +CREATE TABLE IF NOT EXISTS `tt_wechat_account` ( + `account_id` bigint NOT NULL AUTO_INCREMENT COMMENT '账号ID', + `account_name` varchar(100) NOT NULL COMMENT '公众号名称', + `app_id` varchar(64) NOT NULL COMMENT '公众号AppID', + `app_secret` varchar(512) NOT NULL COMMENT 'AES-GCM加密的AppSecret', + `default_author` varchar(32) DEFAULT NULL COMMENT '默认作者', + `default_source_url` varchar(1000) DEFAULT NULL COMMENT '默认阅读原文地址', + `need_open_comment` tinyint NOT NULL DEFAULT 0 COMMENT '是否开启评论', + `only_fans_can_comment` tinyint NOT NULL DEFAULT 0 COMMENT '是否仅粉丝评论', + `status` char(1) NOT NULL DEFAULT '0' COMMENT '状态(0正常 1停用)', + `create_by` varchar(64) DEFAULT '', + `create_time` datetime DEFAULT NULL, + `update_by` varchar(64) DEFAULT '', + `update_time` datetime DEFAULT NULL, + `remark` varchar(500) DEFAULT NULL, + PRIMARY KEY (`account_id`), + UNIQUE KEY `uk_wechat_account_app_id` (`app_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号配置'; + +CREATE TABLE IF NOT EXISTS `tt_wechat_article` ( + `article_id` bigint NOT NULL AUTO_INCREMENT COMMENT '推送记录ID', + `account_id` bigint NOT NULL COMMENT '公众号账号ID', + `code_id` bigint NOT NULL COMMENT '源码项目ID', + `template_id` bigint NOT NULL COMMENT '文案模板ID', + `request_no` varchar(64) NOT NULL COMMENT '幂等请求号', + `title` varchar(128) NOT NULL COMMENT '文章标题', + `author` varchar(64) DEFAULT NULL COMMENT '作者', + `digest` varchar(512) DEFAULT NULL COMMENT '摘要', + `content_markdown` longtext COMMENT '推送时Markdown', + `rendered_html` longtext COMMENT '图片转存前HTML', + `content_html` longtext COMMENT '最终提交微信HTML', + `content_source_url` varchar(1000) DEFAULT NULL COMMENT '阅读原文地址', + `thumb_media_id` varchar(255) DEFAULT NULL COMMENT '封面素材ID', + `show_cover_pic` tinyint NOT NULL DEFAULT 0, + `need_open_comment` tinyint NOT NULL DEFAULT 0, + `only_fans_can_comment` tinyint NOT NULL DEFAULT 0, + `wechat_media_id` varchar(255) DEFAULT NULL COMMENT '微信草稿MediaID', + `push_status` char(1) NOT NULL DEFAULT '2' COMMENT '0成功 1失败 2处理中', + `error_code` int DEFAULT NULL, + `error_message` varchar(1000) DEFAULT NULL, + `retry_count` int NOT NULL DEFAULT 0, + `push_time` datetime DEFAULT NULL, + `create_by` varchar(64) DEFAULT '', + `create_time` datetime DEFAULT NULL, + `update_by` varchar(64) DEFAULT '', + `update_time` datetime DEFAULT NULL, + PRIMARY KEY (`article_id`), + UNIQUE KEY `uk_wechat_article_request_no` (`request_no`), + KEY `idx_wechat_article_account` (`account_id`, `article_id`), + KEY `idx_wechat_article_code` (`code_id`, `article_id`), + KEY `idx_wechat_article_status` (`push_status`, `article_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公众号草稿推送记录'; + +-- 菜单放在源码管理同级。 +SET @wechat_parent_id := COALESCE((SELECT parent_id FROM sys_menu WHERE perms = 'office:code:list' LIMIT 1), 0); +SET @wechat_order_num := COALESCE((SELECT MAX(order_num) + 1 FROM sys_menu WHERE parent_id = @wechat_parent_id), 1); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT '公众号草稿', @wechat_parent_id, @wechat_order_num, + 'wechatDraft', 'office/wechat/index', '', 1, 0, + 'C', '0', '0', 'office:wechat:draft:list', 'wechat', 'admin', NOW(), '', NULL, + '公众号账号和草稿推送记录' +WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'office:wechat:draft:list'); + +SET @wechat_menu_id := (SELECT menu_id FROM sys_menu WHERE perms = 'office:wechat:draft:list' LIMIT 1); + +INSERT INTO sys_menu + (menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, + menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +SELECT p.menu_name, @wechat_menu_id, p.order_num, '', '', '', 1, 0, + 'F', '0', '0', p.perms, '#', 'admin', NOW(), '', NULL, '' +FROM ( + SELECT '账号查询' menu_name, 1 order_num, 'office:wechat:account:list' perms UNION ALL + SELECT '账号详情', 2, 'office:wechat:account:query' UNION ALL + SELECT '新增账号', 3, 'office:wechat:account:add' UNION ALL + SELECT '修改账号', 4, 'office:wechat:account:edit' UNION ALL + SELECT '删除账号', 5, 'office:wechat:account:remove' UNION ALL + SELECT '测试账号', 6, 'office:wechat:account:test' UNION ALL + SELECT '推送草稿', 7, 'office:wechat:draft:push' UNION ALL + SELECT '重试草稿', 8, 'office:wechat:draft:retry' +) p +WHERE @wechat_menu_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu m WHERE m.perms = p.perms);