Skip to content

Commit 6f2f3bc

Browse files
committed
修复多规则目标渠道转发
1 parent 1b4ebdd commit 6f2f3bc

4 files changed

Lines changed: 158 additions & 57 deletions

File tree

main_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,70 @@ func TestNormalizeForwardRule_MigratesSingleTargetAndDeduplicates(t *testing.T)
223223
}
224224
}
225225

226+
func TestPlanForwardTargets_CollectsAllMatchingRulesAndDeduplicates(t *testing.T) {
227+
msg := &Message{AccountID: "acc_1", Subject: "测试通知", Body: "正文"}
228+
rules := []ForwardRule{
229+
{
230+
SourceAccount: "acc_1",
231+
TargetWebhook: "wh_feishu",
232+
},
233+
{
234+
SourceAccounts: []string{"acc_1"},
235+
TargetWebhooks: []string{"wh_dingtalk", "wh_feishu"},
236+
IncludeLinks: true,
237+
},
238+
{
239+
SourceAccount: "acc_other",
240+
TargetWebhook: "wh_other",
241+
},
242+
}
243+
244+
targets, sourceMatched, blockedReasons := planForwardTargets(rules, nil, msg, filterContext{})
245+
246+
if !sourceMatched {
247+
t.Fatal("expected source to match forwarding rules")
248+
}
249+
if len(blockedReasons) != 0 {
250+
t.Fatalf("expected no blocked rules, got %#v", blockedReasons)
251+
}
252+
if len(targets) != 2 {
253+
t.Fatalf("expected two unique targets, got %#v", targets)
254+
}
255+
if targets[0].ID != "wh_feishu" || targets[1].ID != "wh_dingtalk" {
256+
t.Fatalf("expected targets from every matching rule in order, got %#v", targets)
257+
}
258+
if !targets[0].IncludeLinks || !targets[1].IncludeLinks {
259+
t.Fatalf("expected include-links option to be merged per target, got %#v", targets)
260+
}
261+
}
262+
263+
func TestPlanForwardTargets_FilteredRuleDoesNotBlockAnotherMatchingRule(t *testing.T) {
264+
msg := &Message{AccountID: "acc_1", From: "notice@example.com", Subject: "普通通知", Body: "正文"}
265+
filterRules := map[string]FilterRule{
266+
"only_bill": {
267+
ID: "only_bill",
268+
Name: "只允许账单",
269+
Type: "content",
270+
Mode: "whitelist",
271+
Patterns: []string{"账单"},
272+
Enabled: true,
273+
},
274+
}
275+
rules := []ForwardRule{
276+
{SourceAccount: "acc_1", TargetWebhook: "wh_feishu", FilterRuleIDs: []string{"only_bill"}},
277+
{SourceAccount: "acc_1", TargetWebhook: "wh_dingtalk"},
278+
}
279+
280+
targets, sourceMatched, blockedReasons := planForwardTargets(rules, filterRules, msg, filterContext{DisplaySender: msg.From})
281+
282+
if !sourceMatched || len(blockedReasons) != 1 {
283+
t.Fatalf("expected one filtered matching rule, matched=%v reasons=%#v", sourceMatched, blockedReasons)
284+
}
285+
if len(targets) != 1 || targets[0].ID != "wh_dingtalk" {
286+
t.Fatalf("expected allowed matching rule to remain active, got %#v", targets)
287+
}
288+
}
289+
226290
func TestParseInboundPayload_GitHubPush(t *testing.T) {
227291
req, err := http.NewRequest("POST", "/hook/test", strings.NewReader(""))
228292
if err != nil {

processor.go

Lines changed: 84 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import (
66
"time"
77
)
88

9+
type plannedForwardTarget struct {
10+
ID string
11+
IncludeLinks bool
12+
}
13+
914
func processPendingMessages() {
1015
// 防止消息发送队列重叠并发引发重复发送通知
1116
if !processingMutex.TryLock() {
@@ -52,93 +57,73 @@ func processPendingMessages() {
5257
continue
5358
}
5459

55-
// 匹配规则,找到第一条匹配的规则进行发送
56-
ruleMatched := false
57-
filterBlocked := false
58-
for _, rule := range rules {
59-
rule = normalizeForwardRule(rule)
60-
// 检查源账号匹配
61-
if !ruleMatchesSource(rule, msg.AccountID) {
62-
continue
63-
}
64-
65-
filterCtx := buildFilterContext(&msg, accounts)
66-
filterResult := applyFilterRules(rule.FilterRuleIDs, filterRules, &msg, filterCtx)
67-
if !filterResult.Allowed {
68-
filterBlocked = true
69-
addLog(fmt.Sprintf("消息被过滤规则拦截 [%s]: %s", displaySubject(msg.Subject), filterResult.Reason), "info")
70-
continue
71-
}
72-
73-
targetIDs := rule.TargetWebhooks
74-
if len(targetIDs) == 0 {
75-
continue
76-
}
77-
78-
ruleMatched = true
60+
// 汇总所有匹配规则的目标。同一目标被多条规则命中时只发送一次。
61+
filterCtx := buildFilterContext(&msg, accounts)
62+
targets, sourceMatched, filterBlocked := planForwardTargets(rules, filterRules, &msg, filterCtx)
63+
ruleMatched := len(targets) > 0
64+
for _, reason := range filterBlocked {
65+
addLog(fmt.Sprintf("消息被过滤规则拦截 [%s]: %s", displaySubject(msg.Subject), reason), "info")
66+
}
7967

80-
// 发送
81-
var sendErr error
68+
if ruleMatched {
8269
dateStr := displayMessageDate(msg.Date)
8370
subjectForSend := displaySubject(msg.Subject)
8471
senderForSend := filterCtx.DisplaySender
85-
86-
// 智能提取验证码并高亮前置
87-
displayBody := formatForwardBody(msg.Body, rule.IncludeLinks)
88-
var verificationCode string
89-
if matches := codeRegex.FindStringSubmatch(msg.Subject); len(matches) > 1 {
90-
verificationCode = matches[1]
91-
} else if matches := codeRegex.FindStringSubmatch(msg.Body); len(matches) > 1 {
92-
verificationCode = matches[1]
93-
}
94-
95-
if verificationCode != "" {
96-
displayBody = fmt.Sprintf("**[智能提取验证码] %s**\n\n%s", verificationCode, displayBody)
97-
}
98-
99-
sentTargets := make([]string, 0, len(targetIDs))
72+
verificationCode := extractVerificationCode(&msg)
73+
sentTargets := make([]string, 0, len(targets))
10074
failedTargets := make([]string, 0)
101-
for _, targetID := range targetIDs {
102-
webhook, ok := webhooks[targetID]
103-
if !ok || !webhook.Enabled {
75+
historyTargets := make([]string, 0, len(targets))
76+
77+
for _, planned := range targets {
78+
webhook, ok := webhooks[planned.ID]
79+
if !ok {
80+
failedTargets = append(failedTargets, fmt.Sprintf("%s: 目标渠道不存在", planned.ID))
81+
historyTargets = append(historyTargets, planned.ID+"(失败)")
82+
continue
83+
}
84+
if !webhook.Enabled {
85+
failedTargets = append(failedTargets, fmt.Sprintf("%s: 目标渠道已禁用", webhook.Name))
86+
historyTargets = append(historyTargets, webhook.Name+"(失败)")
10487
continue
10588
}
10689

107-
sendErr = sendToWebhookTarget(webhook, accounts, subjectForSend, senderForSend, dateStr, displayBody)
90+
displayBody := formatForwardBody(msg.Body, planned.IncludeLinks)
91+
if verificationCode != "" {
92+
displayBody = fmt.Sprintf("**[智能提取验证码] %s**\n\n%s", verificationCode, displayBody)
93+
}
10894

109-
if sendErr != nil {
110-
failedTargets = append(failedTargets, fmt.Sprintf("%s: %v", webhook.Name, sendErr))
111-
addLog(fmt.Sprintf("发送失败 [%s -> %s]: %v", subjectForSend, webhook.Name, sendErr), "error")
95+
if err := sendToWebhookTarget(webhook, accounts, subjectForSend, senderForSend, dateStr, displayBody); err != nil {
96+
failedTargets = append(failedTargets, fmt.Sprintf("%s: %v", webhook.Name, err))
97+
historyTargets = append(historyTargets, webhook.Name+"(失败)")
98+
addLog(fmt.Sprintf("发送失败 [%s -> %s]: %v", subjectForSend, webhook.Name, err), "error")
11299
continue
113100
}
114101
sentTargets = append(sentTargets, webhook.Name)
102+
historyTargets = append(historyTargets, webhook.Name)
115103
addLog(fmt.Sprintf("转发成功 [%s -> %s]", subjectForSend, webhook.Name), "success")
116104
}
117105

106+
msg.TargetType = "multi"
107+
msg.TargetName = strings.Join(historyTargets, ", ")
118108
if len(sentTargets) == 0 {
119109
msg.RetryCount++
120110
msg.ErrorMessage = strings.Join(failedTargets, "; ")
121-
if msg.ErrorMessage == "" {
122-
msg.ErrorMessage = "没有可用的目标 Webhook"
123-
}
124111
saveMessage(&msg)
125112
} else {
126113
msg.Status = "sent"
127-
msg.TargetType = "multi"
128-
msg.TargetName = strings.Join(sentTargets, ", ")
114+
msg.ErrorMessage = ""
129115
if len(failedTargets) > 0 {
130116
msg.ErrorMessage = "部分目标失败: " + strings.Join(failedTargets, "; ")
131117
}
132118
now := time.Now()
133119
msg.SentAt = &now
134120
saveMessage(&msg)
135121
}
136-
break // 匹配到第一条规则并尝试发送后,不再继续匹配后续规则
137122
}
138123

139-
// 没有匹配到任何转发规则的消息,标记为 no_rule 防止永远卡在 pending 堵塞队列
124+
// 没有匹配到任何转发规则的消息,标记状态防止永远卡在 pending 堵塞队列
140125
if !ruleMatched {
141-
if filterBlocked {
126+
if sourceMatched && len(filterBlocked) > 0 {
142127
msg.Status = "filtered"
143128
msg.ErrorMessage = "已被过滤规则拦截"
144129
saveMessage(&msg)
@@ -153,6 +138,49 @@ func processPendingMessages() {
153138
}
154139
}
155140

141+
func planForwardTargets(rules []ForwardRule, filterRules map[string]FilterRule, msg *Message, filterCtx filterContext) ([]plannedForwardTarget, bool, []string) {
142+
targets := make([]plannedForwardTarget, 0)
143+
targetIndexes := make(map[string]int)
144+
sourceMatched := false
145+
blockedReasons := make([]string, 0)
146+
147+
for _, rule := range rules {
148+
rule = normalizeForwardRule(rule)
149+
if !ruleMatchesSource(rule, msg.AccountID) {
150+
continue
151+
}
152+
sourceMatched = true
153+
154+
filterResult := applyFilterRules(rule.FilterRuleIDs, filterRules, msg, filterCtx)
155+
if !filterResult.Allowed {
156+
blockedReasons = append(blockedReasons, filterResult.Reason)
157+
continue
158+
}
159+
160+
for _, targetID := range rule.TargetWebhooks {
161+
if index, exists := targetIndexes[targetID]; exists {
162+
// 任一匹配规则要求保留链接时,为该目标保留链接。
163+
targets[index].IncludeLinks = targets[index].IncludeLinks || rule.IncludeLinks
164+
continue
165+
}
166+
targetIndexes[targetID] = len(targets)
167+
targets = append(targets, plannedForwardTarget{ID: targetID, IncludeLinks: rule.IncludeLinks})
168+
}
169+
}
170+
171+
return targets, sourceMatched, blockedReasons
172+
}
173+
174+
func extractVerificationCode(msg *Message) string {
175+
if matches := codeRegex.FindStringSubmatch(msg.Subject); len(matches) > 1 {
176+
return matches[1]
177+
}
178+
if matches := codeRegex.FindStringSubmatch(msg.Body); len(matches) > 1 {
179+
return matches[1]
180+
}
181+
return ""
182+
}
183+
156184
func ruleMatchesSource(rule ForwardRule, accountID string) bool {
157185
for _, sourceID := range rule.SourceAccounts {
158186
if sourceID == "all" || sourceID == accountID {

static/app.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,13 +666,14 @@ function renderHistory(messages) {
666666
</span>
667667
<span>
668668
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
669-
${msg.target_name || '-'}
669+
${escapeHtml(msg.target_name || '-')}
670670
</span>
671671
<span>
672672
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
673673
${formatDate(msg.created_at)}
674674
</span>
675675
</div>
676+
${msg.error_message ? `<div class="history-error">${escapeHtml(msg.error_message)}</div>` : ''}
676677
</div>
677678
`).join('');
678679
}

static/style.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,6 +1346,14 @@ textarea.form-input {
13461346
color: var(--text-tertiary);
13471347
}
13481348

1349+
.history-error {
1350+
margin-top: 8px;
1351+
color: var(--error);
1352+
font-size: 0.825rem;
1353+
line-height: 1.5;
1354+
overflow-wrap: anywhere;
1355+
}
1356+
13491357
/* ===================== Scrollbar ===================== */
13501358
::-webkit-scrollbar {
13511359
width: 8px;

0 commit comments

Comments
 (0)