Skip to content

Commit b32405e

Browse files
committed
feat(notification): 添加自定义系统通知内容列支持
- 在 Notification 模型中新增 content 字段和最大长度常量 - 更新 UserProcessor 中的通知长度校验逻辑 - 在 NotificationMgmtService 中实现内容列可用性检测机制 - 添加 NotificationContents 工具类处理通知内容标准化和清理 - 支持 Markdown 链接解析和 HTML 内容安全过滤 - 更新数据库 schema 添加 content 列定义 - 修改通知查询服务支持从新字段读取内容 - 更新文档说明新的通知发送机制
1 parent 7b7511f commit b32405e

File tree

7 files changed

+268
-7
lines changed

7 files changed

+268
-7
lines changed

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@
5858
- 积分变更统一走 `PointtransferMgmtService`:独立流程用 `transfer(...)`(方法内自带事务);若外层已有事务,必须用 `transferInCurrentTransaction(...)` 避免事务冲突。
5959
- 发积分/扣积分约定:发积分用 `fromId=Pointtransfer.ID_C_SYS``toId=userId`;扣积分用 `fromId=userId``toId=Pointtransfer.ID_C_SYS`(常用类型 `Pointtransfer.TRANSFER_TYPE_C_ABUSE_DEDUCT`)。
6060
- 需要给用户发“系统转账通知”时:在转账成功拿到 `transferId` 后,调用 `NotificationMgmtService#addPointTransferNotification`,并设置 `Notification.NOTIFICATION_USER_ID``Notification.NOTIFICATION_DATA_ID=transferId`
61-
- 通知金手指:`POST /user/edit/notification``UserProcessor#sendSystemNotification`),使用 `gold.finger.notification` 校验;请求体使用 `userName` + `notification` + `goldFingerKey`其中 `notification` 最大 64 字符(对应 `notification.dataId` 字段长度)
62-
- 定制系统通知渲染:`Notification.DATA_TYPE_C_CUSTOM_SYS``NotificationQueryService#getSysAnnounceNotifications` 处理;`dataId=1` 为固定“水贴提醒”模板,其他 `dataId` 走 HTML 转义并将换行转为 `<br>`
61+
- 通知金手指:`POST /user/edit/notification``UserProcessor#sendSystemNotification`),使用 `gold.finger.notification` 校验;请求体使用 `userName` + `notification` + `goldFingerKey`新正文优先写 `notification.content`最大 4096),老库未补 `content` 列时仅兼容旧 64 字纯文本 `dataId` 链路
62+
- 定制系统通知渲染:`Notification.DATA_TYPE_C_CUSTOM_SYS``NotificationQueryService#getSysAnnounceNotifications` 处理;`dataId=1` 为固定“水贴提醒”模板,`content` 支持 Markdown 链接 `[文本](http/https://...)` 与换行,原始 HTML 会被转义后再清洗
6363
- VIP 管理页配置项不再手填 JSON:前端依据等级 `benefits` 模板自动生成可视化表单,再序列化为 `configJson` 提交。
6464
- VIP 管理页样式需注意 `home.css``.form--admin label { flex: 1; }` 会影响布局;配置项行在 `vip-admin.scss` 中需显式改为整行(label/builder 100%)并对 checkbox 使用类型选择器,避免控件被放大。
6565
- 有效期字段:勋章 `expireTime`(毫秒,`0`=永久);会员 `expiresAt`(可回填勋章到期)。

src/main/java/org/b3log/symphony/model/Notification.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,21 @@ public final class Notification {
5252
*/
5353
public static final String NOTIFICATION_DATA_TYPE = "dataType";
5454

55+
/**
56+
* Key of content.
57+
*/
58+
public static final String NOTIFICATION_CONTENT = "content";
59+
5560
/**
5661
* Key of has read.
5762
*/
5863
public static final String NOTIFICATION_HAS_READ = "hasRead";
5964

65+
/**
66+
* Max length of custom system notification content.
67+
*/
68+
public static final int MAX_LENGTH_C_CUSTOM_SYS_CONTENT = 4096;
69+
6070
// Data type constants
6171
/**
6272
* Data type - article.

src/main/java/org/b3log/symphony/processor/UserProcessor.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -570,9 +570,9 @@ public void sendSystemNotification(final RequestContext context) {
570570
context.renderMsg("发送失败:notification 不能为空。");
571571
return;
572572
}
573-
if (notification.length() > 64) {
573+
if (notification.length() > Notification.MAX_LENGTH_C_CUSTOM_SYS_CONTENT) {
574574
context.renderJSON(StatusCodes.ERR);
575-
context.renderMsg("发送失败:notification 长度不能超过 64 个字符。");
575+
context.renderMsg("发送失败:notification 长度不能超过 " + Notification.MAX_LENGTH_C_CUSTOM_SYS_CONTENT + " 个字符。");
576576
return;
577577
}
578578

src/main/java/org/b3log/symphony/service/NotificationMgmtService.java

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@
1818
*/
1919
package org.b3log.symphony.service;
2020

21+
import org.apache.commons.lang.StringUtils;
2122
import org.apache.logging.log4j.Level;
2223
import org.apache.logging.log4j.LogManager;
2324
import org.apache.logging.log4j.Logger;
2425
import org.b3log.latke.Keys;
26+
import org.b3log.latke.Latkes;
2527
import org.b3log.latke.ioc.BeanManager;
2628
import org.b3log.latke.ioc.Inject;
2729
import org.b3log.latke.repository.*;
30+
import org.b3log.latke.repository.jdbc.util.Connections;
2831
import org.b3log.latke.repository.annotation.Transactional;
2932
import org.b3log.latke.service.ServiceException;
3033
import org.b3log.latke.service.annotation.Service;
@@ -34,9 +37,13 @@
3437
import org.b3log.symphony.processor.NotificationProcessor;
3538
import org.b3log.symphony.processor.channel.UserChannel;
3639
import org.b3log.symphony.repository.NotificationRepository;
40+
import org.b3log.symphony.util.NotificationContents;
3741
import org.b3log.symphony.util.Symphonys;
3842
import org.json.JSONObject;
3943

44+
import java.sql.Connection;
45+
import java.sql.ResultSet;
46+
import java.sql.SQLException;
4047
import java.util.*;
4148

4249
/**
@@ -54,6 +61,11 @@ public class NotificationMgmtService {
5461
*/
5562
private static final Logger LOGGER = LogManager.getLogger(NotificationMgmtService.class);
5663

64+
/**
65+
* Whether the custom notification content column is available.
66+
*/
67+
private volatile Boolean customSysContentColumnReady;
68+
5769
/**
5870
* Notification repository.
5971
*/
@@ -432,12 +444,29 @@ public void addSysAnnounceArticleNotification(final JSONObject requestJSONObject
432444
@Transactional
433445
public void addSysAnnounceCustomNotification(String notification, String uid) throws ServiceException {
434446
try {
435-
JSONObject requestJSONObject = new JSONObject();
447+
final JSONObject requestJSONObject = new JSONObject();
436448
requestJSONObject.put(Notification.NOTIFICATION_USER_ID, uid);
437-
requestJSONObject.put(Notification.NOTIFICATION_DATA_ID, notification);
449+
if (isCustomSysContentColumnReady()) {
450+
final String rawContent = NotificationContents.normalizeCustomSysContent(notification);
451+
final String safeContent = NotificationContents.sanitizeCustomSysContent(rawContent);
452+
if (StringUtils.isBlank(safeContent)) {
453+
throw new ServiceException("发送失败:notification 清洗后为空,请至少保留文本或 http/https 链接。");
454+
}
455+
requestJSONObject.put(Notification.NOTIFICATION_DATA_ID, StringUtils.EMPTY);
456+
requestJSONObject.put(Notification.NOTIFICATION_CONTENT, rawContent);
457+
} else {
458+
if (NotificationContents.requiresDedicatedContentColumn(notification)) {
459+
throw new ServiceException("当前数据库缺少 notification.content 列,请先执行 SQL:ALTER TABLE `"
460+
+ Latkes.getLocalProperty("jdbc.tablePrefix") + "_notification` ADD COLUMN `content` TEXT;");
461+
}
462+
requestJSONObject.put(Notification.NOTIFICATION_DATA_ID,
463+
NotificationContents.normalizeCustomSysContent(notification));
464+
}
438465
requestJSONObject.put(Notification.NOTIFICATION_DATA_TYPE, Notification.DATA_TYPE_C_CUSTOM_SYS);
439466

440467
addNotification(requestJSONObject);
468+
} catch (final ServiceException e) {
469+
throw e;
441470
} catch (final RepositoryException e) {
442471
final String msg = "Adds a notification [type=sys_announce_custom] failed";
443472
LOGGER.log(Level.ERROR, msg, e);
@@ -446,6 +475,27 @@ public void addSysAnnounceCustomNotification(String notification, String uid) th
446475
}
447476
}
448477

478+
/**
479+
* Returns whether the custom system notification content column is ready.
480+
*
481+
* @return {@code true} if ready
482+
* @throws ServiceException service exception
483+
*/
484+
public boolean isCustomSysContentColumnReady() throws ServiceException {
485+
if (null != customSysContentColumnReady) {
486+
return customSysContentColumnReady;
487+
}
488+
489+
synchronized (this) {
490+
if (null != customSysContentColumnReady) {
491+
return customSysContentColumnReady;
492+
}
493+
494+
customSysContentColumnReady = detectCustomSysContentColumn();
495+
return customSysContentColumnReady;
496+
}
497+
}
498+
449499
/**
450500
* Adds a 'sys announce - new user' type notification with the specified request json object.
451501
*
@@ -988,6 +1038,9 @@ private void addNotification(final JSONObject requestJSONObject) throws Reposito
9881038
notification.put(Notification.NOTIFICATION_USER_ID, requestJSONObject.optString(Notification.NOTIFICATION_USER_ID));
9891039
notification.put(Notification.NOTIFICATION_DATA_ID, requestJSONObject.optString(Notification.NOTIFICATION_DATA_ID));
9901040
notification.put(Notification.NOTIFICATION_DATA_TYPE, requestJSONObject.optInt(Notification.NOTIFICATION_DATA_TYPE));
1041+
if (requestJSONObject.has(Notification.NOTIFICATION_CONTENT)) {
1042+
notification.put(Notification.NOTIFICATION_CONTENT, requestJSONObject.optString(Notification.NOTIFICATION_CONTENT));
1043+
}
9911044

9921045
notificationRepository.add(notification);
9931046

@@ -999,4 +1052,22 @@ private void addNotification(final JSONObject requestJSONObject) throws Reposito
9991052
UserChannel.sendCmd(cmd);
10001053
});
10011054
}
1055+
1056+
/**
1057+
* Detects whether the custom content column exists on the notification table.
1058+
*
1059+
* @return {@code true} if the column exists
1060+
* @throws ServiceException service exception
1061+
*/
1062+
private boolean detectCustomSysContentColumn() throws ServiceException {
1063+
final String tableName = Latkes.getLocalProperty("jdbc.tablePrefix") + "_" + Notification.NOTIFICATION;
1064+
try (Connection connection = Connections.getConnection();
1065+
ResultSet resultSet = connection.getMetaData()
1066+
.getColumns(connection.getCatalog(), null, tableName, Notification.NOTIFICATION_CONTENT)) {
1067+
return resultSet.next();
1068+
} catch (final SQLException e) {
1069+
LOGGER.log(Level.ERROR, "Detects notification.content column failed", e);
1070+
throw new ServiceException("检测 notification.content 列失败,请检查数据库连接。");
1071+
}
1072+
}
10021073
}

src/main/java/org/b3log/symphony/service/NotificationQueryService.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import org.b3log.symphony.repository.*;
3737
import org.b3log.symphony.util.Escapes;
3838
import org.b3log.symphony.util.Emotions;
39+
import org.b3log.symphony.util.NotificationContents;
3940
import org.b3log.symphony.util.Symphonys;
4041
import org.json.JSONObject;
4142

@@ -278,7 +279,12 @@ public JSONObject getSysAnnounceNotifications(final String userId, final int cur
278279
desTemplate = desTemplate.replace("{oldRole}", oldRole.optString(Role.ROLE_NAME));
279280
desTemplate = desTemplate.replace("{newRole}", newRole.optString(Role.ROLE_NAME));
280281
break;
281-
case Notification.DATA_TYPE_C_CUSTOM_SYS:
282+
case Notification.DATA_TYPE_C_CUSTOM_SYS: {
283+
final String customContent = notification.optString(Notification.NOTIFICATION_CONTENT);
284+
if (StringUtils.isNotBlank(customContent)) {
285+
desTemplate = NotificationContents.sanitizeCustomSysContent(customContent);
286+
break;
287+
}
282288
switch (dataId) {
283289
case "1":
284290
desTemplate = "亲爱的用户,您好!<br> 我们检测到您刚刚发布的内容可能属于“水贴”或无意义发言。虽然本次发帖不会被拦截,但频繁发布此类内容会破坏社区的交流氛围,影响其他用户的体验。请您尽量发表有价值、有内容的观点或讨论,让我们的社区更加温暖和有趣。<br>需要特别提醒的是,无论是否通过特殊方式绕过系统检测,所有水贴内容都会被系统或人工审核查处,账号也可能因此受到相应处理。同时,本次发帖不会计入您的活跃度统计。感谢您的理解与配合,让我们一起维护良好的社区环境!";
@@ -291,6 +297,7 @@ public JSONObject getSysAnnounceNotifications(final String userId, final int cur
291297
desTemplate = Escapes.escapeHTML(dataId).replace("\r\n", "<br>").replace("\n", "<br>").replace("\r", "<br>");
292298
}
293299
break;
300+
}
294301
default:
295302
throw new AssertionError();
296303
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* Rhythm - A modern community (forum/BBS/SNS/blog) platform written in Java.
3+
* Modified version from Symphony, Thanks Symphony :)
4+
* Copyright (C) 2012-present, b3log.org
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
*/
19+
package org.b3log.symphony.util;
20+
21+
import org.apache.commons.lang.StringUtils;
22+
import org.jsoup.Jsoup;
23+
import org.jsoup.nodes.Document;
24+
import org.jsoup.safety.Whitelist;
25+
26+
import java.net.MalformedURLException;
27+
import java.net.URL;
28+
import java.util.regex.Pattern;
29+
30+
/**
31+
* Notification content utilities.
32+
*
33+
* @author <a href="https://fishpi.cn/member/admin">admin</a>
34+
* @version 1.0.0.0, Mar 12, 2026
35+
* @since 1.0.0
36+
*/
37+
public final class NotificationContents {
38+
39+
/**
40+
* Legacy dataId length limit.
41+
*/
42+
public static final int LEGACY_CUSTOM_SYS_DATA_ID_LENGTH = 64;
43+
44+
/**
45+
* Whitelist for custom system notifications.
46+
*/
47+
private static final Whitelist CUSTOM_SYS_WHITELIST = Whitelist.none()
48+
.addTags("a", "br")
49+
.addAttributes("a", "href", "target", "rel")
50+
.addProtocols("a", "href", "http", "https");
51+
52+
/**
53+
* Output settings.
54+
*/
55+
private static final Document.OutputSettings OUTPUT_SETTINGS = new Document.OutputSettings().prettyPrint(false);
56+
57+
/**
58+
* HTML tag detection pattern.
59+
*/
60+
private static final Pattern HTML_TAG_PATTERN = Pattern.compile(".*<\\s*/?\\s*[a-zA-Z][^>]*>.*", Pattern.DOTALL);
61+
62+
/**
63+
* Private constructor.
64+
*/
65+
private NotificationContents() {
66+
}
67+
68+
/**
69+
* Normalizes custom system notification input.
70+
*
71+
* @param content the raw content
72+
* @return normalized content
73+
*/
74+
public static String normalizeCustomSysContent(final String content) {
75+
return StringUtils.trimToEmpty(content)
76+
.replace("\r\n", "\n")
77+
.replace("\r", "\n");
78+
}
79+
80+
/**
81+
* Markdown link detection pattern.
82+
*/
83+
private static final Pattern MARKDOWN_LINK_PATTERN = Pattern.compile("\\[([^\\]\\r\\n]+)]\\((https?://[^\\s)]+)\\)");
84+
85+
/**
86+
* Sanitizes custom system notification content. Only markdown links and line breaks are kept.
87+
*
88+
* @param content the raw content
89+
* @return sanitized HTML
90+
*/
91+
public static String sanitizeCustomSysContent(final String content) {
92+
if (StringUtils.isBlank(content)) {
93+
return StringUtils.EMPTY;
94+
}
95+
96+
final String normalized = normalizeCustomSysContent(content);
97+
final String withLinks = replaceMarkdownLinks(Escapes.escapeHTML(normalized));
98+
final String cleaned = Jsoup.clean(withLinks.replace("\n", "<br>"), StringUtils.EMPTY, CUSTOM_SYS_WHITELIST, OUTPUT_SETTINGS);
99+
final Document document = Jsoup.parseBodyFragment(cleaned);
100+
document.outputSettings(OUTPUT_SETTINGS);
101+
return document.body().html();
102+
}
103+
104+
/**
105+
* Returns whether the content needs the dedicated content column.
106+
*
107+
* @param content the raw content
108+
* @return {@code true} if the legacy dataId field is not enough
109+
*/
110+
public static boolean requiresDedicatedContentColumn(final String content) {
111+
if (StringUtils.length(content) > LEGACY_CUSTOM_SYS_DATA_ID_LENGTH) {
112+
return true;
113+
}
114+
115+
final String safeContent = StringUtils.defaultString(content);
116+
return HTML_TAG_PATTERN.matcher(safeContent).matches() || MARKDOWN_LINK_PATTERN.matcher(safeContent).find();
117+
}
118+
119+
/**
120+
* Checks whether a link is a safe HTTP/HTTPS URL.
121+
*
122+
* @param href the href to check
123+
* @return {@code true} if safe
124+
*/
125+
private static boolean isSafeHttpUrl(final String href) {
126+
if (StringUtils.isBlank(href)) {
127+
return false;
128+
}
129+
130+
try {
131+
final URL url = new URL(href);
132+
final String protocol = StringUtils.lowerCase(url.getProtocol());
133+
return "http".equals(protocol) || "https".equals(protocol);
134+
} catch (final MalformedURLException e) {
135+
return false;
136+
}
137+
}
138+
139+
/**
140+
* Replaces markdown links with safe anchor HTML.
141+
*
142+
* @param content the escaped content
143+
* @return replaced content
144+
*/
145+
private static String replaceMarkdownLinks(final String content) {
146+
final StringBuilder builder = new StringBuilder();
147+
final java.util.regex.Matcher matcher = MARKDOWN_LINK_PATTERN.matcher(content);
148+
int lastEnd = 0;
149+
while (matcher.find()) {
150+
final String href = StringUtils.trimToEmpty(matcher.group(2));
151+
if (!isSafeHttpUrl(href)) {
152+
continue;
153+
}
154+
155+
builder.append(content, lastEnd, matcher.start());
156+
builder.append("<a href=\"")
157+
.append(href)
158+
.append("\" target=\"_blank\" rel=\"nofollow noopener noreferrer\">")
159+
.append(matcher.group(1))
160+
.append("</a>");
161+
lastEnd = matcher.end();
162+
}
163+
164+
builder.append(content, lastEnd, content.length());
165+
return builder.toString();
166+
}
167+
}

src/main/resources/repository.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,12 @@
10191019
"length": 64,
10201020
"description": "数据实体 id"
10211021
},
1022+
{
1023+
"name": "content",
1024+
"type": "String",
1025+
"length": 4096,
1026+
"description": "定制系统通知正文"
1027+
},
10221028
{
10231029
"name": "dataType",
10241030
"type": "int",

0 commit comments

Comments
 (0)