Commit abed96ecc930d3cb5cb3900c8c25a07798d346db

Authored by C77
1 parent 4caae18d

feat(mail): 增强邮件模板参数解析和HTML内容格式化功能

- 修改邮件模板创建和更新逻辑,同时解析标题和内容中的参数
- 增强邮件内容格式化方法,支持HTML特殊字符反转义
- 实现代码块格式化,为<pre><code>标签添加样式
- 替换最外层pre标签为div标签以改善显示效果
- 添加完整的HTML邮件模板处理测试用例
- 扩展参数解析方法以合并标题和内容中的参数并去重
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/mail/MailTemplateServiceImpl.java
... ... @@ -19,8 +19,10 @@ import org.springframework.cache.annotation.Cacheable;
19 19 import org.springframework.stereotype.Service;
20 20 import org.springframework.validation.annotation.Validated;
21 21  
  22 +import java.util.ArrayList;
22 23 import java.util.List;
23 24 import java.util.Map;
  25 +import java.util.regex.Matcher;
24 26 import java.util.regex.Pattern;
25 27  
26 28 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
... ... @@ -53,7 +55,7 @@ public class MailTemplateServiceImpl implements MailTemplateService {
53 55  
54 56 // 插入
55 57 MailTemplateDO template = BeanUtils.toBean(createReqVO, MailTemplateDO.class)
56   - .setParams(parseTemplateContentParams(createReqVO.getContent()));
  58 + .setParams(parseTemplateTitleAndContentParams(createReqVO.getTitle(), createReqVO.getContent()));
57 59 mailTemplateMapper.insert(template);
58 60 return template.getId();
59 61 }
... ... @@ -69,7 +71,7 @@ public class MailTemplateServiceImpl implements MailTemplateService {
69 71  
70 72 // 更新
71 73 MailTemplateDO updateObj = BeanUtils.toBean(updateReqVO, MailTemplateDO.class)
72   - .setParams(parseTemplateContentParams(updateReqVO.getContent()));
  74 + .setParams(parseTemplateTitleAndContentParams(updateReqVO.getTitle(), updateReqVO.getContent()));
73 75 mailTemplateMapper.updateById(updateObj);
74 76 }
75 77  
... ... @@ -129,13 +131,108 @@ public class MailTemplateServiceImpl implements MailTemplateService {
129 131  
130 132 @Override
131 133 public String formatMailTemplateContent(String content, Map<String, Object> params) {
132   - return StrUtil.format(content, params);
  134 + // 先替换模板变量
  135 + String formattedContent = StrUtil.format(content, params);
  136 +
  137 + // 反转义HTML特殊字符
  138 + formattedContent = unescapeHtml(formattedContent);
  139 +
  140 + // 处理代码块(确保<pre><code>标签格式正确)
  141 + formattedContent = formatHtmlCodeBlocks(formattedContent);
  142 +
  143 + // 将最外层的pre标签替换为div标签
  144 + formattedContent = replaceOuterPreWithDiv(formattedContent);
  145 +
  146 + return formattedContent;
  147 + }
  148 +
  149 + private String replaceOuterPreWithDiv(String content) {
  150 + if (content == null) {
  151 + return null;
  152 + }
  153 +
  154 + // 使用正则表达式匹配所有的<pre>标签,包括嵌套的<code>标签
  155 + String regex = "(?s)<pre[^>]*>(.*?)</pre>";
  156 + Pattern pattern = Pattern.compile(regex);
  157 + Matcher matcher = pattern.matcher(content);
  158 +
  159 + StringBuffer sb = new StringBuffer();
  160 + while (matcher.find()) {
  161 + // 提取<pre>标签内的内容
  162 + String innerContent = matcher.group(1);
  163 + // 返回div标签包裹的内容
  164 + matcher.appendReplacement(sb, "<div>" + innerContent + "</div>");
  165 + }
  166 + matcher.appendTail(sb);
  167 +
  168 + return sb.toString();
  169 + }
  170 +
  171 + /**
  172 + * 反转义HTML特殊字符
  173 + *
  174 + * @param input 输入字符串
  175 + * @return 反转义后的字符串
  176 + */
  177 + private String unescapeHtml(String input) {
  178 + if (input == null) {
  179 + return null;
  180 + }
  181 + return input
  182 + .replace("&amp;", "&")
  183 + .replace("&lt;", "<")
  184 + .replace("&gt;", ">")
  185 + .replace("&quot;", "\"")
  186 + .replace("&#39;", "'")
  187 + .replace("&nbsp;", " ");
  188 + }
  189 +
  190 + /**
  191 + * 格式化HTML中的代码块
  192 + *
  193 + * @param content 邮件内容
  194 + * @return 格式化后的邮件内容
  195 + */
  196 + private String formatHtmlCodeBlocks(String content) {
  197 + // 匹配<pre><code>标签的代码块
  198 + Pattern codeBlockPattern = Pattern.compile("<pre\\s*.*?><code\\s*.*?>(.*?)</code></pre>", Pattern.DOTALL);
  199 + Matcher matcher = codeBlockPattern.matcher(content);
  200 +
  201 + StringBuffer sb = new StringBuffer();
  202 + while (matcher.find()) {
  203 + // 获取代码块内容
  204 + String codeBlock = matcher.group(1);
  205 +
  206 + // 为代码块添加样式
  207 + String replacement = "<pre style=\"background-color: #f5f5f5; padding: 10px; border-radius: 5px; overflow-x: auto;\"><code>" + codeBlock + "</code></pre>";
  208 + matcher.appendReplacement(sb, replacement);
  209 + }
  210 + matcher.appendTail(sb);
  211 +
  212 + return sb.toString();
133 213 }
134 214  
135 215 @Override
136 216 public long getMailTemplateCountByAccountId(Long accountId) {
137 217 return mailTemplateMapper.selectCountByAccountId(accountId);
138 218 }
  219 + /**
  220 + * 解析标题和内容中的参数
  221 + */
  222 + @VisibleForTesting
  223 + public List<String> parseTemplateTitleAndContentParams(String title, String content) {
  224 + List<String> titleParams = ReUtil.findAllGroup1(PATTERN_PARAMS, title);
  225 + List<String> contentParams = ReUtil.findAllGroup1(PATTERN_PARAMS, content);
  226 +
  227 + // 合并参数并去重
  228 + List<String> allParams = new ArrayList<>(titleParams);
  229 + for (String param : contentParams) {
  230 + if (!allParams.contains(param)) {
  231 + allParams.add(param);
  232 + }
  233 + }
  234 + return allParams;
  235 + }
139 236  
140 237 /**
141 238 * 获得邮件模板中的参数,形如 {key}
... ... @@ -143,8 +240,8 @@ public class MailTemplateServiceImpl implements MailTemplateService {
143 240 * @param content 内容
144 241 * @return 参数列表
145 242 */
146   - private List<String> parseTemplateContentParams(String content) {
  243 + List<String> parseTemplateContentParams(String content) {
147 244 return ReUtil.findAllGroup1(PATTERN_PARAMS, content);
148 245 }
149 246  
150 247 -}
  248 +}
151 249 \ No newline at end of file
... ...
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/mail/MailTemplateServiceImplTest.java
... ... @@ -197,6 +197,119 @@ public class MailTemplateServiceImplTest extends BaseDbUnitTest {
197 197 }
198 198  
199 199 @Test
  200 + public void testFormatMailTemplateContent_htmlUnescape() {
  201 + // 准备参数
  202 + Map<String, Object> params = new HashMap<>();
  203 + params.put("title", "测试标题");
  204 +
  205 + // 测试HTML反转义
  206 + String content = "<h1>{title}</h1>&lt;p&gt;这是一个测试&lt;/p&gt;&amp;nbsp;空格";
  207 + String expected = "<h1>测试标题</h1><p>这是一个测试</p> 空格";
  208 + // 调用,并断言
  209 + assertEquals(expected,
  210 + mailTemplateService.formatMailTemplateContent(content, params));
  211 + }
  212 +
  213 + @Test
  214 + public void testFormatMailTemplateContent_codeBlockFormatting() {
  215 + // 准备参数
  216 + Map<String, Object> params = new HashMap<>();
  217 + params.put("name", "测试");
  218 +
  219 + // 测试代码块格式化
  220 + String content = "<pre><code>public class Test {\n public static void main(String[] args) {\n System.out.println(\"Hello {name}\"));\n }\n}</code></pre>";
  221 +
  222 + // 调用,并断言结果
  223 + String result = mailTemplateService.formatMailTemplateContent(content, params);
  224 + // 断言pre标签被替换为div标签
  225 + assertTrue(result.contains("<div><code>public class Test {"));
  226 + assertTrue(result.contains("System.out.println(\"Hello 测试\""));
  227 + assertTrue(result.contains("</code></div>"));
  228 + }
  229 +
  230 + @Test
  231 + public void testFormatMailTemplateContent_preToDiv() {
  232 + // 准备参数
  233 + Map<String, Object> params = new HashMap<>();
  234 + params.put("content", "测试内容");
  235 +
  236 + // 测试pre标签替换为div标签
  237 + String content = "<pre><code>{content}</code></pre>";
  238 + String result = mailTemplateService.formatMailTemplateContent(content, params);
  239 + // 断言结果中包含div标签,而不包含pre标签
  240 + assertTrue(result.contains("<div><code>测试内容</code></div>"));
  241 + }
  242 +
  243 + @Test
  244 + public void testFormatMailTemplateContent_completeHtml() {
  245 + // 准备参数
  246 + Map<String, Object> params = new HashMap<>();
  247 + params.put("username", "testuser");
  248 + params.put("company", "测试公司");
  249 +
  250 + // 测试完整的HTML邮件模板
  251 + String content = "<!DOCTYPE html>\n <html lang=\"en\">\n <head>\n <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">\n <meta charset=\"UTF-8\">\n <title>Title</title>\n </head>\n <body>\n <div>\n <includetail>\n <div>\n <div class=\"open_email\" style=\"margin: 8px; \">\n <div>\n <br>\n <span class=\"genEmailContent\">\n <div id=\"cTMail-Wrap\" style=\"word-break: break-all;box-sizing:border-box;text-align:left;min-width:320px; max-width:660px; border:1px solid #f6f6f6; background-color:#f7f8fa; margin:auto; padding:20px 0 30px; font-family:'helvetica neue',PingFangSC-Light,arial,'hiragino sans gb','microsoft yahei ui','microsoft yahei',simsun,sans-serif\">\n <div class=\"main-content\">\n <table style=\"width:100%;font-weight:300;margin-bottom:10px;border-collapse:collapse\">\n <tbody>\n <tr style=\"font-weight:300\">\n <td style=\"width:3%;max-width:30px;\"></td>\n <td style=\"max-width:600px;\">\n <div id=\"cTMail-logo\" style=\"width:92px; height:36px;\">\n <a href=\"\">\n <img border=\"0\" src=\"左上角图片logo\" style=\"width:120px; height:36px;display:block\">\n </a>\n </div>\n <div style=\"color: #C2C5C9;width: 260px;float: right;font-size: 12px;\">此邮件由系统发出,请勿直接回复或转发他人</div>\n <p style=\"height:2px;background-color: #00a4ff;border: 0;font-size:0;padding:0;width:100%;margin-top:20px;\"></p>\n <div id=\"cTMail-inner\" style=\"background-color:#fff; padding:23px 0 20px;box-shadow: 0px 1px 1px 0px rgba(122, 55, 55, 0.2);text-align:left;\">\n <table style=\"width:100%;font-weight:300;margin-bottom:10px;border-collapse:collapse;\">\n <tbody>\n <tr style=\"font-weight:300\">\n <td style=\"width:3.2%;max-width:30px;\"></td>\n <td style=\"max-width:480px;\">\n <h1 id=\"cTMail-title\" style=\"font-size: 20px; line-height: 36px; margin: 0px 0px 22px;\">\n 尊敬的 {username},\n </h1>\n <dl style=\"font-size: 14px; color: #595E65; line-height: 18px;\">\n <dd style=\"margin: 0px 0px 6px; padding: 0px; font-size: 14px; line-height: 22px;\">\n <p id=\"cTMail-sender\" style=\"font-size: 14px; line-height: 26px; word-wrap: break-word; word-break: break-all; margin-top: 32px;\">\n 内容<br>\n 内容<br>\n 内容123<br><br>\n\n 如果您在使用过程中遇到任何问题或者有任何建议,都可以随时联系我们的客户团队,我们将竭诚为您服务。\n\n\n\n\n <br>\n <br>\n {company}<br>\n 地址:xxxxx<br>\n 邮箱:lambc77@163.com\n </p>\n </dd>\n </dl>\n <hr style=\"border: 0.1px solid #e5e5e5;\"/>\n <dl style=\"font-size: 14px; color: #595E65; line-height: 18px;\">\n <div style=\"color: #93979B;\">\n <strong>声明:本邮件含有保密信息,仅限于收件人所用。禁止任何人未经发件人许可,以任何形式(包括但不限于部分的泄露、复制或散发)不当的使用本邮件中的信息。如果您错收了本邮件,请您立即电话或邮件通知发件人并删除本邮件,谢谢!\n </strong><br>\n </div>\n </dl>\n </td>\n <td style=\"width:3.2%;max-width:30px;\"></td>\n </tr>\n </tbody>\n </table>\n </div>\n </td>\n <td style=\"width:3%;max-width:30px;\"></td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n </span>\n </div>\n </div>\n </div>\n </includetail>\n </div>\n </body>\n <script>\n\n </script>\n </html>";
  252 +
  253 + // 调用,并断言成功处理
  254 + String result = mailTemplateService.formatMailTemplateContent(content, params);
  255 + // 断言结果中包含替换后的变量
  256 + assertTrue(result.contains("尊敬的 testuser"));
  257 + assertTrue(result.contains("测试公司"));
  258 + // 断言结果是有效的HTML
  259 + assertTrue(result.startsWith("<!DOCTYPE html>"));
  260 + }
  261 +
  262 + @Test
  263 + public void testFormatMailTemplateContent_emptyContent() {
  264 + // 准备参数
  265 + Map<String, Object> params = new HashMap<>();
  266 +
  267 + // 测试空内容
  268 + String result = mailTemplateService.formatMailTemplateContent("", params);
  269 + assertEquals("", result);
  270 + }
  271 +
  272 + @Test
  273 + public void testFormatMailTemplateContent_noParams() {
  274 + // 准备参数
  275 + Map<String, Object> params = new HashMap<>();
  276 +
  277 + // 测试没有参数需要替换的情况
  278 + String content = "<pre><code>System.out.println(\"Hello World\");</code></pre>";
  279 + String result = mailTemplateService.formatMailTemplateContent(content, params);
  280 + assertTrue(result.contains("<div><code>System.out.println(\"Hello World\");</code></div>"));
  281 + }
  282 +
  283 + @Test
  284 + public void testFormatMailTemplateContent_multiplePreTags() {
  285 + // 准备参数
  286 + Map<String, Object> params = new HashMap<>();
  287 + params.put("param1", "value1");
  288 + params.put("param2", "value2");
  289 +
  290 + // 测试多个pre标签的情况
  291 + String content = "<pre><code>First code block: {param1}</code></pre>\n" +
  292 + "<p>Some text between code blocks</p>\n" +
  293 + "<pre><code>Second code block: {param2}</code></pre>";
  294 + String result = mailTemplateService.formatMailTemplateContent(content, params);
  295 + // 断言两个pre标签都被替换为div标签
  296 + assertTrue(result.contains("<div><code>First code block: value1</code></div>"));
  297 + assertTrue(result.contains("<div><code>Second code block: value2</code></div>"));
  298 + }
  299 +
  300 + @Test
  301 + public void testFormatMailTemplateContent_specialCharacters() {
  302 + // 准备参数
  303 + Map<String, Object> params = new HashMap<>();
  304 +
  305 + // 简化测试,只测试基本的HTML特殊字符
  306 + String content = "&lt;div&gt;测试 &amp; 特殊字符&lt;/div&gt;";
  307 + String result = mailTemplateService.formatMailTemplateContent(content, params);
  308 + // 断言特殊字符被正确反转义
  309 + assertTrue(result.contains("<div>测试 & 特殊字符</div>"));
  310 + }
  311 +
  312 + @Test
200 313 public void testCountByAccountId() {
201 314 // mock 数据
202 315 MailTemplateDO dbMailTemplate = randomPojo(MailTemplateDO.class);
... ... @@ -212,4 +325,60 @@ public class MailTemplateServiceImplTest extends BaseDbUnitTest {
212 325 assertEquals(1, count);
213 326 }
214 327  
  328 + @Test
  329 + public void testDifferenceWithHtmlContent() {
  330 + // 准备包含HTML格式的模板内容
  331 + String content = "<div style='font-family: Arial, sans-serif; color: #333;'>" +
  332 + "<h1>Welcome, {username}!</h1>" +
  333 + "<p>Your account has been created successfully.</p>" +
  334 + "<div style='background-color: #f0f0f0; padding: 10px; border-radius: 5px;'>" +
  335 + "<strong>Account Details:</strong><br>" +
  336 + "Username: {username}<br>" +
  337 + "Email: {email}<br>" +
  338 + "Role: {role}<br>" +
  339 + "</div>" +
  340 + "<p>Please click <a href='{activationLink}'>here</a> to activate your account.</p>" +
  341 + "<pre><code>public class WelcomeMessage {\n public static void main(String[] args) {\n System.out.println(\"Hello {username}!\");\n }\n}</code></pre>" +
  342 + "</div>";
  343 +
  344 + Map<String, Object> params = new HashMap<>();
  345 + params.put("username", "testuser");
  346 + params.put("email", "test@163.com");
  347 + params.put("role", "admin");
  348 + params.put("activationLink", "https://example.com/activate?code=12345");
  349 +
  350 + // 1. 使用parseTemplateContentParams:只提取参数名称,忽略了HTML格式
  351 + List<String> parsedParams = mailTemplateService.parseTemplateContentParams(content);
  352 + System.out.println("parseTemplateContentParams结果:" + parsedParams);
  353 +
  354 + // 断言:只提取了纯参数名称,没有HTML格式
  355 + assertEquals(6, parsedParams.size());
  356 + // 检查所有参数类型
  357 + assertTrue(parsedParams.stream().filter("username"::equals).count() == 3);
  358 + assertTrue(parsedParams.stream().filter("email"::equals).count() == 1);
  359 + assertTrue(parsedParams.stream().filter("role"::equals).count() == 1);
  360 + assertTrue(parsedParams.stream().filter("activationLink"::equals).count() == 1);
  361 + // 断言:没有包含任何HTML标签
  362 + for (String param : parsedParams) {
  363 + assertFalse(param.contains("<"));
  364 + assertFalse(param.contains(">"));
  365 + }
  366 +
  367 + // 2. 使用formatMailTemplateContent:处理HTML格式,生成最终内容
  368 + String formattedContent = mailTemplateService.formatMailTemplateContent(content, params);
  369 + System.out.println("formatMailTemplateContent结果:" + formattedContent);
  370 +
  371 + // 断言:HTML格式被保留并处理
  372 + assertTrue(formattedContent.contains("<div style='font-family: Arial, sans-serif; color: #333;'>"));
  373 + assertTrue(formattedContent.contains("<h1>Welcome, testuser!</h1>"));
  374 + assertTrue(formattedContent.contains("<a href='https://example.com/activate?code=12345'>here</a>"));
  375 + assertTrue(formattedContent.contains("<div><code>public class WelcomeMessage {"));
  376 + assertTrue(formattedContent.contains("</code></div>"));
  377 + // 断言:所有参数都被正确替换
  378 + assertFalse(formattedContent.contains("{username}"));
  379 + assertFalse(formattedContent.contains("{email}"));
  380 + assertFalse(formattedContent.contains("{role}"));
  381 + assertFalse(formattedContent.contains("{activationLink}"));
  382 + }
  383 +
215 384 }
... ...