Files
tamabox/internal/shared/duplicate.go
T
tamakyi e3e59e7c69 chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty).
Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak,
then full worktree committed as a rollback baseline.
2026-09-10 10:56:27 +08:00

95 lines
2.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package shared
import (
"crypto/md5"
"encoding/hex"
"strings"
"sync"
"time"
)
// DuplicateBlocker 阻止同一来源(IP + 内容指纹)在窗口期内的重复提交,
// 用于拦截刷屏 / 重复轰炸提问。
//
// 实现:内存 map,key = normalizedIP + "|" + contentHash,值为首次提交时间。
// 窗口期内再次提交相同内容返回 false(拒绝)。
//
// 内存安全:条数超过 50000 时先清理过期项;窗口外的 key 由后台清理循环定时移除。
type DuplicateBlocker struct {
mu sync.Mutex
items map[string]time.Time
window time.Duration
maxItem int
}
// NewDuplicateBlocker 创建拦截器并启动后台清理循环。
// 注意:每个实例会启动一个常驻 goroutine,应用内应使用包级单例 DuplicateGuard,
// 不要为每次请求创建新实例。
func NewDuplicateBlocker(window time.Duration) *DuplicateBlocker {
d := &DuplicateBlocker{
items: make(map[string]time.Time),
window: window,
maxItem: 50000,
}
go func() {
for {
time.Sleep(10 * time.Minute)
d.cleanup()
}
}()
return d
}
// Allow 判断 key(IP+内容)是否允许提交:
// - 窗口期内已存在相同 key → 返回 false(重复,拒绝)
// - 否则记录并返回 true
func (d *DuplicateBlocker) Allow(key string) bool {
now := time.Now()
cutoff := now.Add(-d.window)
d.mu.Lock()
defer d.mu.Unlock()
if t, ok := d.items[key]; ok && t.After(cutoff) {
return false
}
// 超限时先清理过期项,避免恶意构造大量 key 撑爆内存
if len(d.items) >= d.maxItem {
for k, t := range d.items {
if !t.After(cutoff) {
delete(d.items, k)
}
}
// 仍超限则整体放弃写入(宁可放行也不撑爆内存)
if len(d.items) >= d.maxItem {
return true
}
}
d.items[key] = now
return true
}
func (d *DuplicateBlocker) cleanup() {
cutoff := time.Now().Add(-d.window)
d.mu.Lock()
for k, t := range d.items {
if !t.After(cutoff) {
delete(d.items, k)
}
}
d.mu.Unlock()
}
// DuplicateKey 生成拦截 key:规范化 IP + 内容 MD5。
func DuplicateKey(ip, content string) string {
ip = NormalizeIP(ip)
sum := md5.Sum([]byte(strings.TrimSpace(content)))
return ip + "|" + hex.EncodeToString(sum[:])
}
// DuplicateGuard 全局限流实例:同 IP 在 5 分钟内提交相同内容会被拒绝。
// 阈值合理:正常用户几乎不可能在 5 分钟内提交两条完全一致的提问。
var DuplicateGuard = NewDuplicateBlocker(5 * time.Minute)