Commit abed96ecc930d3cb5cb3900c8c25a07798d346db
1 parent
4caae18d
feat(mail): 增强邮件模板参数解析和HTML内容格式化功能
- 修改邮件模板创建和更新逻辑,同时解析标题和内容中的参数 - 增强邮件内容格式化方法,支持HTML特殊字符反转义 - 实现代码块格式化,为<pre><code>标签添加样式 - 替换最外层pre标签为div标签以改善显示效果 - 添加完整的HTML邮件模板处理测试用例 - 扩展参数解析方法以合并标题和内容中的参数并去重
Showing
2 changed files
with
271 additions
and
5 deletions
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("&", "&") | |
| 183 | + .replace("<", "<") | |
| 184 | + .replace(">", ">") | |
| 185 | + .replace(""", "\"") | |
| 186 | + .replace("'", "'") | |
| 187 | + .replace(" ", " "); | |
| 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><p>这是一个测试</p>&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 = "<div>测试 & 特殊字符</div>"; | |
| 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 | } | ... | ... |