DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,191 @@
|
||||
package moderation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/platform/httpx"
|
||||
)
|
||||
|
||||
var ErrAdminMuxRequired = errors.New("moderation admin mux is required")
|
||||
|
||||
const maxAdminJSONBytes = 1 << 20
|
||||
|
||||
type AdminOptions struct {
|
||||
Prefix string
|
||||
Wrap func(http.HandlerFunc) http.HandlerFunc
|
||||
}
|
||||
|
||||
func RegisterAdminRoutes(mux *http.ServeMux, engine *Engine, options AdminOptions) error {
|
||||
return RegisterAdminRoutesWithEvaluator(mux, engine, engine, options)
|
||||
}
|
||||
|
||||
func RegisterAdminRoutesWithEvaluator(mux *http.ServeMux, engine *Engine, evaluator Evaluator, options AdminOptions) error {
|
||||
if mux == nil {
|
||||
return ErrAdminMuxRequired
|
||||
}
|
||||
prefix := normalizeAdminPrefix(options.Prefix)
|
||||
wrap := options.Wrap
|
||||
if wrap == nil {
|
||||
wrap = func(next http.HandlerFunc) http.HandlerFunc { return next }
|
||||
}
|
||||
mux.HandleFunc(prefix, wrap(SnapshotHandler(engine)))
|
||||
mux.HandleFunc(prefix+"/snapshot", wrap(SnapshotHandler(engine)))
|
||||
mux.HandleFunc(prefix+"/evaluate", wrap(EvaluateWithHandler(evaluator)))
|
||||
mux.HandleFunc(prefix+"/sanctions", wrap(SanctionsHandler(engine)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func SnapshotHandler(engine *Engine) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
if engine == nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "moderation engine is required"})
|
||||
return
|
||||
}
|
||||
var filter FilterSnapshot
|
||||
if engine.Filter() != nil {
|
||||
filter = engine.Filter().Snapshot()
|
||||
}
|
||||
var sanctions SanctionSnapshot
|
||||
var err error
|
||||
if store := engine.Sanctions(); store != nil {
|
||||
sanctions, err = store.Snapshot(r.Context())
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"filter": filter,
|
||||
"sanctions": sanctions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func EvaluateHandler(engine *Engine) http.HandlerFunc {
|
||||
return EvaluateWithHandler(engine)
|
||||
}
|
||||
|
||||
func EvaluateWithHandler(evaluator Evaluator) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
if evaluator == nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "moderation evaluator is required"})
|
||||
return
|
||||
}
|
||||
var request Request
|
||||
if err := decodeModAdminJSON(w, r, &request); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
decision, err := evaluator.Evaluate(r.Context(), request)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, decision)
|
||||
}
|
||||
}
|
||||
|
||||
func SanctionsHandler(engine *Engine) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if engine == nil || engine.Sanctions() == nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "moderation sanction store is required"})
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
handleGetSanctions(w, r, engine.Sanctions())
|
||||
case http.MethodPost:
|
||||
handlePostSanction(w, r, engine.Sanctions())
|
||||
case http.MethodDelete:
|
||||
handleDeleteSanction(w, r, engine.Sanctions())
|
||||
default:
|
||||
w.Header().Set("Allow", strings.Join([]string{http.MethodGet, http.MethodPost, http.MethodDelete}, ", "))
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetSanctions(w http.ResponseWriter, r *http.Request, store SanctionStore) {
|
||||
subject := strings.TrimSpace(r.URL.Query().Get("subject"))
|
||||
if subject != "" {
|
||||
items, err := store.Active(r.Context(), SanctionQuery{
|
||||
Subject: subject,
|
||||
Scope: r.URL.Query().Get("scope"),
|
||||
})
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
return
|
||||
}
|
||||
snapshot, err := store.Snapshot(r.Context())
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, snapshot)
|
||||
}
|
||||
|
||||
func handlePostSanction(w http.ResponseWriter, r *http.Request, store SanctionStore) {
|
||||
var sanction Sanction
|
||||
if err := decodeModAdminJSON(w, r, &sanction); err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
item, err := store.Upsert(r.Context(), sanction)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"sanction": item})
|
||||
}
|
||||
|
||||
// decodeModAdminJSON 统一限制管理端 JSON 的大小、字段和单文档格式。
|
||||
func decodeModAdminJSON(w http.ResponseWriter, r *http.Request, out any) error {
|
||||
return httpx.DecodeStrictJSON(w, r, maxAdminJSONBytes, out)
|
||||
}
|
||||
|
||||
func handleDeleteSanction(w http.ResponseWriter, r *http.Request, store SanctionStore) {
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
if err := store.Remove(r.Context(), id); err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, ErrSanctionMissing) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func normalizeAdminPrefix(prefix string) string {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return "/debug/moderation"
|
||||
}
|
||||
if !strings.HasPrefix(prefix, "/") {
|
||||
prefix = "/" + prefix
|
||||
}
|
||||
prefix = strings.TrimRight(prefix, "/")
|
||||
if prefix == "" {
|
||||
return "/debug/moderation"
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Subject string `json:"subject"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Now time.Time `json:"now,omitempty"`
|
||||
}
|
||||
|
||||
type Decision struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Subject string `json:"subject"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
OriginalText string `json:"original_text,omitempty"`
|
||||
SanitizedText string `json:"sanitized_text,omitempty"`
|
||||
Text TextResult `json:"text"`
|
||||
Sanctions []Sanction `json:"sanctions,omitempty"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
mu sync.RWMutex
|
||||
filter *Filter
|
||||
store SanctionStore
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewEngine(filter *Filter, store SanctionStore) *Engine {
|
||||
if filter == nil {
|
||||
filter, _ = NewFilter()
|
||||
}
|
||||
if store == nil {
|
||||
store = NewMemorySanctionStore()
|
||||
}
|
||||
return &Engine{
|
||||
filter: filter,
|
||||
store: store,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) SetNow(now func() time.Time) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.now = now
|
||||
e.mu.Unlock()
|
||||
if store, ok := e.store.(*MemorySanctionStore); ok {
|
||||
store.SetNow(now)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) Evaluate(ctx context.Context, request Request) (Decision, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if e == nil {
|
||||
return Decision{}, fmt.Errorf("%w: engine is nil", ErrInvalidRequest)
|
||||
}
|
||||
subject := normalizeToken(request.Subject)
|
||||
if subject == "" {
|
||||
return Decision{}, fmt.Errorf("%w: subject is required", ErrInvalidRequest)
|
||||
}
|
||||
scope := normalizeToken(request.Scope)
|
||||
now := request.Now
|
||||
if now.IsZero() {
|
||||
now = e.nowUTC()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
|
||||
decision := Decision{
|
||||
Allowed: true,
|
||||
Subject: subject,
|
||||
Scope: scope,
|
||||
OriginalText: request.Text,
|
||||
SanitizedText: request.Text,
|
||||
Text: TextResult{Original: request.Text, Sanitized: request.Text},
|
||||
}
|
||||
|
||||
if e.store != nil {
|
||||
sanctions, err := e.store.Active(ctx, SanctionQuery{Subject: subject, Scope: scope, Now: now})
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
decision.Sanctions = sanctions
|
||||
for _, sanction := range sanctions {
|
||||
switch sanction.Kind {
|
||||
case SanctionBan:
|
||||
decision.Allowed = false
|
||||
decision.Reasons = append(decision.Reasons, reasonForSanction(sanction))
|
||||
case SanctionMute:
|
||||
if strings.TrimSpace(request.Text) != "" {
|
||||
decision.Allowed = false
|
||||
decision.Reasons = append(decision.Reasons, reasonForSanction(sanction))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if e.filter != nil && request.Text != "" {
|
||||
text := e.filter.Evaluate(scope, request.Text)
|
||||
decision.Text = text
|
||||
decision.SanitizedText = text.Sanitized
|
||||
if text.Rejected {
|
||||
decision.Allowed = false
|
||||
decision.Reasons = append(decision.Reasons, "text_rejected")
|
||||
}
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func (e *Engine) Filter() *Filter {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.filter
|
||||
}
|
||||
|
||||
func (e *Engine) Sanctions() SanctionStore {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.store
|
||||
}
|
||||
|
||||
func reasonForSanction(sanction Sanction) string {
|
||||
if sanction.Scope == "" {
|
||||
return string(sanction.Kind)
|
||||
}
|
||||
return string(sanction.Kind) + ":" + sanction.Scope
|
||||
}
|
||||
|
||||
func (e *Engine) nowUTC() time.Time {
|
||||
if e == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
e.mu.RLock()
|
||||
now := e.now
|
||||
e.mu.RUnlock()
|
||||
if now == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return now().UTC()
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidWordRule = errors.New("moderation word rule is invalid")
|
||||
ErrInvalidRequest = errors.New("moderation request is invalid")
|
||||
)
|
||||
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionMask Action = "mask"
|
||||
ActionReject Action = "reject"
|
||||
)
|
||||
|
||||
type WordRule struct {
|
||||
ID string `json:"id"`
|
||||
Word string `json:"word"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Action Action `json:"action,omitempty"`
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
Severity int `json:"severity,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
IgnoreSeparators bool `json:"ignore_separators,omitempty"`
|
||||
WholeWord bool `json:"whole_word,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type WordSnapshot struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Rules []WordRule `json:"rules,omitempty"`
|
||||
}
|
||||
|
||||
type FilterSnapshot struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Rules []WordRule `json:"rules,omitempty"`
|
||||
}
|
||||
|
||||
type Match struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
Word string `json:"word"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Action Action `json:"action"`
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
Severity int `json:"severity,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Start int `json:"start"`
|
||||
End int `json:"end"`
|
||||
}
|
||||
|
||||
type TextResult struct {
|
||||
Original string `json:"original"`
|
||||
Sanitized string `json:"sanitized"`
|
||||
Rejected bool `json:"rejected"`
|
||||
Matches []Match `json:"matches,omitempty"`
|
||||
}
|
||||
|
||||
type Filter struct {
|
||||
mu sync.RWMutex
|
||||
version string
|
||||
updatedAt time.Time
|
||||
rules []compiledRule
|
||||
}
|
||||
|
||||
type compiledRule struct {
|
||||
rule WordRule
|
||||
normalized []rune
|
||||
}
|
||||
|
||||
type normalizedRune struct {
|
||||
value rune
|
||||
originalIndex int
|
||||
}
|
||||
|
||||
func NewFilter(rules ...WordRule) (*Filter, error) {
|
||||
filter := &Filter{}
|
||||
if len(rules) > 0 {
|
||||
if err := filter.ReplaceRules(context.Background(), WordSnapshot{Rules: rules}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
func (f *Filter) ReplaceRules(ctx context.Context, snapshot WordSnapshot) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
compiled := make([]compiledRule, 0, len(snapshot.Rules))
|
||||
seen := make(map[string]struct{}, len(snapshot.Rules))
|
||||
for _, rule := range snapshot.Rules {
|
||||
item, err := normalizeWordRule(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists := seen[item.ID]; exists {
|
||||
return fmt.Errorf("%w: duplicate id %q", ErrInvalidWordRule, item.ID)
|
||||
}
|
||||
seen[item.ID] = struct{}{}
|
||||
normalized := normalizeRuleWord(item.Word, item.IgnoreSeparators)
|
||||
if len(normalized) == 0 {
|
||||
return fmt.Errorf("%w: word %q has no comparable characters", ErrInvalidWordRule, item.ID)
|
||||
}
|
||||
compiled = append(compiled, compiledRule{rule: item, normalized: normalized})
|
||||
}
|
||||
sort.Slice(compiled, func(i, j int) bool {
|
||||
if compiled[i].rule.Scope != compiled[j].rule.Scope {
|
||||
return compiled[i].rule.Scope < compiled[j].rule.Scope
|
||||
}
|
||||
if len(compiled[i].normalized) != len(compiled[j].normalized) {
|
||||
return len(compiled[i].normalized) > len(compiled[j].normalized)
|
||||
}
|
||||
return compiled[i].rule.ID < compiled[j].rule.ID
|
||||
})
|
||||
|
||||
f.mu.Lock()
|
||||
f.version = strings.TrimSpace(snapshot.Version)
|
||||
f.updatedAt = snapshot.UpdatedAt.UTC()
|
||||
if f.updatedAt.IsZero() && len(compiled) > 0 {
|
||||
f.updatedAt = time.Now().UTC()
|
||||
}
|
||||
f.rules = compiled
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Filter) ApplyJSON(ctx context.Context, data []byte) error {
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return f.ReplaceRules(ctx, WordSnapshot{})
|
||||
}
|
||||
var snapshot WordSnapshot
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
return fmt.Errorf("decode moderation word snapshot: %w", err)
|
||||
}
|
||||
return f.ReplaceRules(ctx, snapshot)
|
||||
}
|
||||
|
||||
func (f *Filter) Evaluate(scope, text string) TextResult {
|
||||
result := TextResult{Original: text, Sanitized: text}
|
||||
if f == nil || text == "" {
|
||||
return result
|
||||
}
|
||||
scope = normalizeToken(scope)
|
||||
runes := []rune(text)
|
||||
if len(runes) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
f.mu.RLock()
|
||||
rules := append([]compiledRule(nil), f.rules...)
|
||||
f.mu.RUnlock()
|
||||
if len(rules) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
matches := make([]Match, 0)
|
||||
normalizedText := map[bool][]normalizedRune{
|
||||
false: normalizeText(runes, false),
|
||||
true: normalizeText(runes, true),
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if !ruleApplies(rule.rule.Scope, scope) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, matchRule(rule, runes, normalizedText[rule.rule.IgnoreSeparators])...)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return result
|
||||
}
|
||||
sortMatches(matches)
|
||||
|
||||
mask := make([]rune, len(runes))
|
||||
for _, match := range matches {
|
||||
if match.Action == ActionReject {
|
||||
result.Rejected = true
|
||||
}
|
||||
if match.Action != ActionMask {
|
||||
continue
|
||||
}
|
||||
replacement := replacementRune(match.Replacement)
|
||||
for i := match.Start; i <= match.End && i < len(mask); i++ {
|
||||
if !unicode.IsSpace(runes[i]) {
|
||||
mask[i] = replacement
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range runes {
|
||||
if mask[i] != 0 {
|
||||
runes[i] = mask[i]
|
||||
}
|
||||
}
|
||||
result.Sanitized = string(runes)
|
||||
result.Matches = matches
|
||||
return result
|
||||
}
|
||||
|
||||
func (f *Filter) Snapshot() FilterSnapshot {
|
||||
if f == nil {
|
||||
return FilterSnapshot{}
|
||||
}
|
||||
f.mu.RLock()
|
||||
rules := make([]WordRule, 0, len(f.rules))
|
||||
for _, rule := range f.rules {
|
||||
rules = append(rules, cloneWordRule(rule.rule))
|
||||
}
|
||||
version := f.version
|
||||
updatedAt := f.updatedAt
|
||||
f.mu.RUnlock()
|
||||
return FilterSnapshot{Version: version, UpdatedAt: updatedAt, Rules: rules}
|
||||
}
|
||||
|
||||
func normalizeWordRule(rule WordRule) (WordRule, error) {
|
||||
rule.ID = normalizeToken(rule.ID)
|
||||
rule.Word = strings.TrimSpace(rule.Word)
|
||||
rule.Scope = normalizeToken(rule.Scope)
|
||||
rule.Replacement = strings.TrimSpace(rule.Replacement)
|
||||
rule.Tags = normalizeList(rule.Tags)
|
||||
rule.Meta = cloneStringMap(rule.Meta)
|
||||
if rule.ID == "" {
|
||||
rule.ID = normalizeToken(rule.Word)
|
||||
}
|
||||
if rule.ID == "" || rule.Word == "" {
|
||||
return WordRule{}, fmt.Errorf("%w: id and word are required", ErrInvalidWordRule)
|
||||
}
|
||||
if rule.Action == "" {
|
||||
rule.Action = ActionMask
|
||||
}
|
||||
if rule.Action != ActionMask && rule.Action != ActionReject {
|
||||
return WordRule{}, fmt.Errorf("%w: invalid action %q", ErrInvalidWordRule, rule.Action)
|
||||
}
|
||||
if rule.Replacement == "" {
|
||||
rule.Replacement = "*"
|
||||
}
|
||||
if rule.Severity < 0 {
|
||||
return WordRule{}, fmt.Errorf("%w: severity must be >= 0", ErrInvalidWordRule)
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func matchRule(rule compiledRule, text []rune, normalized []normalizedRune) []Match {
|
||||
if len(normalized) == 0 || len(normalized) < len(rule.normalized) {
|
||||
return nil
|
||||
}
|
||||
out := make([]Match, 0)
|
||||
for i := 0; i <= len(normalized)-len(rule.normalized); i++ {
|
||||
ok := true
|
||||
for j := range rule.normalized {
|
||||
if normalized[i+j].value != rule.normalized[j] {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
start := normalized[i].originalIndex
|
||||
end := normalized[i+len(rule.normalized)-1].originalIndex
|
||||
if rule.rule.WholeWord && !hasWordBoundary(text, start, end) {
|
||||
continue
|
||||
}
|
||||
out = append(out, Match{
|
||||
RuleID: rule.rule.ID,
|
||||
Word: rule.rule.Word,
|
||||
Scope: rule.rule.Scope,
|
||||
Action: rule.rule.Action,
|
||||
Replacement: rule.rule.Replacement,
|
||||
Severity: rule.rule.Severity,
|
||||
Tags: append([]string(nil), rule.rule.Tags...),
|
||||
Start: start,
|
||||
End: end,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacementRune(value string) rune {
|
||||
for _, r := range value {
|
||||
return r
|
||||
}
|
||||
return '*'
|
||||
}
|
||||
|
||||
func normalizeRuleWord(word string, ignoreSeparators bool) []rune {
|
||||
out := make([]rune, 0, len(word))
|
||||
for _, r := range word {
|
||||
if ignoreSeparators && isSeparator(r) {
|
||||
continue
|
||||
}
|
||||
out = append(out, unicode.ToLower(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeText(text []rune, ignoreSeparators bool) []normalizedRune {
|
||||
out := make([]normalizedRune, 0, len(text))
|
||||
for idx, r := range text {
|
||||
if ignoreSeparators && isSeparator(r) {
|
||||
continue
|
||||
}
|
||||
out = append(out, normalizedRune{value: unicode.ToLower(r), originalIndex: idx})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isSeparator(r rune) bool {
|
||||
return unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r)
|
||||
}
|
||||
|
||||
func hasWordBoundary(text []rune, start, end int) bool {
|
||||
if start > 0 && isWordRune(text[start-1]) {
|
||||
return false
|
||||
}
|
||||
if end+1 < len(text) && isWordRune(text[end+1]) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isWordRune(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
func sortMatches(matches []Match) {
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
if matches[i].Start != matches[j].Start {
|
||||
return matches[i].Start < matches[j].Start
|
||||
}
|
||||
leftLen := matches[i].End - matches[i].Start
|
||||
rightLen := matches[j].End - matches[j].Start
|
||||
if leftLen != rightLen {
|
||||
return leftLen > rightLen
|
||||
}
|
||||
if matches[i].Severity != matches[j].Severity {
|
||||
return matches[i].Severity > matches[j].Severity
|
||||
}
|
||||
return matches[i].RuleID < matches[j].RuleID
|
||||
})
|
||||
}
|
||||
|
||||
func ruleApplies(ruleScope, requestScope string) bool {
|
||||
ruleScope = normalizeToken(ruleScope)
|
||||
requestScope = normalizeToken(requestScope)
|
||||
return ruleScope == "" || ruleScope == "global" || ruleScope == requestScope
|
||||
}
|
||||
|
||||
func normalizeToken(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func normalizeList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = normalizeToken(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneWordRule(rule WordRule) WordRule {
|
||||
rule.Tags = append([]string(nil), rule.Tags...)
|
||||
rule.Meta = cloneStringMap(rule.Meta)
|
||||
return rule
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Evaluator interface {
|
||||
Evaluate(context.Context, Request) (Decision, error)
|
||||
}
|
||||
|
||||
type ExternalProvider interface {
|
||||
EvaluateText(context.Context, Request, TextResult) (ProviderDecision, error)
|
||||
}
|
||||
|
||||
type ProviderDecision struct {
|
||||
Rejected bool `json:"rejected,omitempty"`
|
||||
SanitizedText string `json:"sanitized_text,omitempty"`
|
||||
Reasons []string `json:"reasons,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CacheTTL time.Duration `json:"-"`
|
||||
}
|
||||
|
||||
type PipelineOptions struct {
|
||||
Engine *Engine
|
||||
Provider ExternalProvider
|
||||
CacheTTL time.Duration
|
||||
FailOpen bool
|
||||
ErrorReason string
|
||||
RejectReason string
|
||||
ProviderScope string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Pipeline struct {
|
||||
engine *Engine
|
||||
provider ExternalProvider
|
||||
cacheTTL time.Duration
|
||||
failOpen bool
|
||||
errorReason string
|
||||
rejectReason string
|
||||
providerScope string
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
cache map[string]cachedProvDecision
|
||||
}
|
||||
|
||||
type cachedProvDecision struct {
|
||||
decision ProviderDecision
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func NewPipeline(options PipelineOptions) *Pipeline {
|
||||
engine := options.Engine
|
||||
if engine == nil {
|
||||
engine = NewEngine(nil, nil)
|
||||
}
|
||||
errorReason := normalizeToken(options.ErrorReason)
|
||||
if errorReason == "" {
|
||||
errorReason = "external_unavailable"
|
||||
}
|
||||
rejectReason := normalizeToken(options.RejectReason)
|
||||
if rejectReason == "" {
|
||||
rejectReason = "external_rejected"
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Pipeline{
|
||||
engine: engine,
|
||||
provider: options.Provider,
|
||||
cacheTTL: options.CacheTTL,
|
||||
failOpen: options.FailOpen,
|
||||
errorReason: errorReason,
|
||||
rejectReason: rejectReason,
|
||||
providerScope: normalizeToken(options.ProviderScope),
|
||||
now: now,
|
||||
cache: make(map[string]cachedProvDecision),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pipeline) Evaluate(ctx context.Context, request Request) (Decision, error) {
|
||||
if p == nil {
|
||||
return Decision{}, fmt.Errorf("%w: pipeline is nil", ErrInvalidRequest)
|
||||
}
|
||||
if p.engine == nil {
|
||||
return Decision{}, fmt.Errorf("%w: engine is required", ErrInvalidRequest)
|
||||
}
|
||||
decision, err := p.engine.Evaluate(ctx, request)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if p.provider == nil || !decision.Allowed || strings.TrimSpace(request.Text) == "" {
|
||||
return decision, nil
|
||||
}
|
||||
providerDecision, err := p.evaluateProvider(ctx, request, decision.Text)
|
||||
if err != nil {
|
||||
if p.failOpen {
|
||||
return decision, nil
|
||||
}
|
||||
decision.Allowed = false
|
||||
decision.Reasons = appendReasons(decision.Reasons, p.errorReason)
|
||||
return decision, nil
|
||||
}
|
||||
return applyProviderChoice(decision, providerDecision, p.rejectReason), nil
|
||||
}
|
||||
|
||||
func (p *Pipeline) ClearCache() {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.cache = make(map[string]cachedProvDecision)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *Pipeline) Engine() *Engine {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.engine
|
||||
}
|
||||
|
||||
func (p *Pipeline) evaluateProvider(ctx context.Context, request Request, local TextResult) (ProviderDecision, error) {
|
||||
key := p.cacheKey(request)
|
||||
now := p.nowUTC()
|
||||
if key != "" {
|
||||
if decision, ok := p.cachedDecision(key, now); ok {
|
||||
return decision, nil
|
||||
}
|
||||
}
|
||||
decision, err := p.provider.EvaluateText(ctx, request, local)
|
||||
if err != nil {
|
||||
return ProviderDecision{}, err
|
||||
}
|
||||
decision.Reasons = normalizeList(decision.Reasons)
|
||||
decision.Tags = normalizeList(decision.Tags)
|
||||
if key != "" {
|
||||
p.storeCachedDecision(key, decision, now)
|
||||
}
|
||||
return cloneProviderChoice(decision), nil
|
||||
}
|
||||
|
||||
func (p *Pipeline) cachedDecision(key string, now time.Time) (ProviderDecision, bool) {
|
||||
p.mu.Lock()
|
||||
item, ok := p.cache[key]
|
||||
if !ok {
|
||||
p.mu.Unlock()
|
||||
return ProviderDecision{}, false
|
||||
}
|
||||
if !item.expiresAt.IsZero() && !item.expiresAt.After(now) {
|
||||
delete(p.cache, key)
|
||||
p.mu.Unlock()
|
||||
return ProviderDecision{}, false
|
||||
}
|
||||
p.mu.Unlock()
|
||||
return cloneProviderChoice(item.decision), true
|
||||
}
|
||||
|
||||
func (p *Pipeline) storeCachedDecision(key string, decision ProviderDecision, now time.Time) {
|
||||
ttl := decision.CacheTTL
|
||||
if ttl <= 0 {
|
||||
ttl = p.cacheTTL
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return
|
||||
}
|
||||
item := cachedProvDecision{
|
||||
decision: cloneProviderChoice(decision),
|
||||
expiresAt: now.Add(ttl),
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.cache[key] = item
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *Pipeline) cacheKey(request Request) string {
|
||||
text := strings.TrimSpace(request.Text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
scope := normalizeToken(request.Scope)
|
||||
if p.providerScope != "" {
|
||||
scope = encodeModerationKey(p.providerScope, scope)
|
||||
}
|
||||
return encodeModerationKey(scope, text)
|
||||
}
|
||||
|
||||
func (p *Pipeline) nowUTC() time.Time {
|
||||
if p == nil || p.now == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return p.now().UTC()
|
||||
}
|
||||
|
||||
func applyProviderChoice(decision Decision, provider ProviderDecision, rejectReason string) Decision {
|
||||
if provider.SanitizedText != "" {
|
||||
decision.SanitizedText = provider.SanitizedText
|
||||
decision.Text.Sanitized = provider.SanitizedText
|
||||
}
|
||||
if provider.Rejected {
|
||||
decision.Allowed = false
|
||||
reasons := provider.Reasons
|
||||
if len(reasons) == 0 {
|
||||
reasons = []string{rejectReason}
|
||||
}
|
||||
decision.Reasons = appendReasons(decision.Reasons, reasons...)
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
func appendReasons(existing []string, values ...string) []string {
|
||||
if len(values) == 0 {
|
||||
return existing
|
||||
}
|
||||
seen := make(map[string]struct{}, len(existing)+len(values))
|
||||
out := make([]string, 0, len(existing)+len(values))
|
||||
for _, value := range existing {
|
||||
key := normalizeToken(value)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, key)
|
||||
}
|
||||
for _, value := range values {
|
||||
key := normalizeToken(value)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneProviderChoice(decision ProviderDecision) ProviderDecision {
|
||||
decision.Reasons = append([]string(nil), decision.Reasons...)
|
||||
decision.Tags = append([]string(nil), decision.Tags...)
|
||||
return decision
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/db"
|
||||
)
|
||||
|
||||
var ErrNilRedisStoreExecutor = errors.New("moderation redis executor is required")
|
||||
|
||||
const defRedisSanctionPref = "longheng:moderation"
|
||||
|
||||
const sanctionUpsertLua = `
|
||||
local old_subject_key = KEYS[4]
|
||||
redis.call("HSET", KEYS[1], ARGV[1], ARGV[2])
|
||||
redis.call("SADD", KEYS[2], ARGV[1])
|
||||
redis.call("SADD", KEYS[3], ARGV[1])
|
||||
if old_subject_key ~= "" and old_subject_key ~= KEYS[3] then
|
||||
redis.call("SREM", old_subject_key, ARGV[1])
|
||||
end
|
||||
return 1
|
||||
`
|
||||
|
||||
const sanctionRmScript = `
|
||||
local current = redis.call("HGET", KEYS[1], ARGV[1])
|
||||
if not current then
|
||||
return 0
|
||||
end
|
||||
if current ~= ARGV[2] then
|
||||
return 2
|
||||
end
|
||||
redis.call("HDEL", KEYS[1], ARGV[1])
|
||||
redis.call("SREM", KEYS[2], ARGV[1])
|
||||
redis.call("SREM", KEYS[3], ARGV[1])
|
||||
return 1
|
||||
`
|
||||
|
||||
const sanctionRmRetries = 3
|
||||
|
||||
type RedisSanctionStoreOptions struct {
|
||||
Executor db.RedisExecutor
|
||||
KeyPrefix string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type RedisSanctionStore struct {
|
||||
executor db.RedisExecutor
|
||||
prefix string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewRedisSanctionStore(options RedisSanctionStoreOptions) *RedisSanctionStore {
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &RedisSanctionStore{
|
||||
executor: options.Executor,
|
||||
prefix: normSanctionPref(options.KeyPrefix),
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RedisSanctionStore) Upsert(ctx context.Context, sanction Sanction) (Sanction, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Sanction{}, err
|
||||
}
|
||||
executor, prefix, now, err := s.ready()
|
||||
if err != nil {
|
||||
return Sanction{}, err
|
||||
}
|
||||
item, err := normalizeSanction(sanction, now().UTC())
|
||||
if err != nil {
|
||||
return Sanction{}, err
|
||||
}
|
||||
data, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return Sanction{}, err
|
||||
}
|
||||
itemsKey := sanctionItemsKey(prefix)
|
||||
oldSubjectKey := sanctionSubjectKey(prefix, item.Subject)
|
||||
if existing, ok, err := redisSanctionByID(ctx, executor, itemsKey, item.ID); err != nil {
|
||||
return Sanction{}, err
|
||||
} else if ok {
|
||||
oldSubjectKey = sanctionSubjectKey(prefix, existing.Subject)
|
||||
}
|
||||
if _, err := redisDo(ctx, executor, "EVAL",
|
||||
sanctionUpsertLua,
|
||||
4,
|
||||
itemsKey,
|
||||
sanctionIndexKey(prefix),
|
||||
sanctionSubjectKey(prefix, item.Subject),
|
||||
oldSubjectKey,
|
||||
item.ID,
|
||||
data,
|
||||
); err != nil {
|
||||
return Sanction{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *RedisSanctionStore) Remove(ctx context.Context, id string) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
id = normalizeToken(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("%w: id is required", ErrInvalidSanction)
|
||||
}
|
||||
executor, prefix, _, err := s.ready()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
itemsKey := sanctionItemsKey(prefix)
|
||||
for attempt := 0; attempt < sanctionRmRetries; attempt++ {
|
||||
item, raw, ok, err := redisSanctionRaw(ctx, executor, itemsKey, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrSanctionMissing
|
||||
}
|
||||
status, err := redisInt64Value(redisDo(ctx, executor, "EVAL",
|
||||
sanctionRmScript,
|
||||
3,
|
||||
itemsKey,
|
||||
sanctionIndexKey(prefix),
|
||||
sanctionSubjectKey(prefix, item.Subject),
|
||||
id,
|
||||
raw,
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch status {
|
||||
case 0:
|
||||
return ErrSanctionMissing
|
||||
case 1:
|
||||
return nil
|
||||
case 2:
|
||||
continue
|
||||
default:
|
||||
return fmt.Errorf("unexpected moderation redis remove status %d", status)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: concurrent remove conflict", ErrInvalidSanction)
|
||||
}
|
||||
|
||||
func (s *RedisSanctionStore) Active(ctx context.Context, query SanctionQuery) ([]Sanction, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
executor, prefix, now, err := s.ready()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query.Subject = normalizeToken(query.Subject)
|
||||
query.Scope = normalizeToken(query.Scope)
|
||||
if query.Subject == "" {
|
||||
return nil, fmt.Errorf("%w: subject is required", ErrInvalidSanction)
|
||||
}
|
||||
if query.Now.IsZero() {
|
||||
query.Now = now().UTC()
|
||||
} else {
|
||||
query.Now = query.Now.UTC()
|
||||
}
|
||||
ids, err := redisStrings(ctx, executor, "SMEMBERS", sanctionSubjectKey(prefix, query.Subject))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kindSet := sanctionKindSet(query.Kinds)
|
||||
out := make([]Sanction, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
item, ok, err := redisSanctionByID(ctx, executor, sanctionItemsKey(prefix), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok || item.Subject != query.Subject {
|
||||
_, _ = redisDo(ctx, executor, "SREM", sanctionSubjectKey(prefix, query.Subject), id)
|
||||
continue
|
||||
}
|
||||
if !sanctionScopeApplies(item.Scope, query.Scope) || !item.ActiveAt(query.Now) {
|
||||
continue
|
||||
}
|
||||
if len(kindSet) > 0 {
|
||||
if _, ok := kindSet[item.Kind]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, cloneSanction(item))
|
||||
}
|
||||
sortSanctions(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *RedisSanctionStore) Snapshot(ctx context.Context) (SanctionSnapshot, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return SanctionSnapshot{}, err
|
||||
}
|
||||
executor, prefix, _, err := s.ready()
|
||||
if err != nil {
|
||||
return SanctionSnapshot{}, err
|
||||
}
|
||||
ids, err := redisStrings(ctx, executor, "SMEMBERS", sanctionIndexKey(prefix))
|
||||
if err != nil {
|
||||
return SanctionSnapshot{}, err
|
||||
}
|
||||
items := make([]Sanction, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
item, ok, err := redisSanctionByID(ctx, executor, sanctionItemsKey(prefix), id)
|
||||
if err != nil {
|
||||
return SanctionSnapshot{}, err
|
||||
}
|
||||
if !ok {
|
||||
_, _ = redisDo(ctx, executor, "SREM", sanctionIndexKey(prefix), id)
|
||||
continue
|
||||
}
|
||||
items = append(items, cloneSanction(item))
|
||||
}
|
||||
sortSanctions(items)
|
||||
return SanctionSnapshot{Items: items}, nil
|
||||
}
|
||||
|
||||
func redisSanctionByID(ctx context.Context, executor db.RedisExecutor, itemsKey, id string) (Sanction, bool, error) {
|
||||
item, _, ok, err := redisSanctionRaw(ctx, executor, itemsKey, id)
|
||||
return item, ok, err
|
||||
}
|
||||
|
||||
func redisSanctionRaw(ctx context.Context, executor db.RedisExecutor, itemsKey, id string) (Sanction, []byte, bool, error) {
|
||||
value, err := redisDo(ctx, executor, "HGET", itemsKey, normalizeToken(id))
|
||||
if err != nil {
|
||||
return Sanction{}, nil, false, err
|
||||
}
|
||||
data, ok, err := redisBulkBytes(value)
|
||||
if err != nil || !ok {
|
||||
return Sanction{}, nil, ok, err
|
||||
}
|
||||
var item Sanction
|
||||
if err := json.Unmarshal(data, &item); err != nil {
|
||||
return Sanction{}, nil, false, fmt.Errorf("decode moderation redis sanction: %w", err)
|
||||
}
|
||||
return cloneSanction(item), data, true, nil
|
||||
}
|
||||
|
||||
func redisStrings(ctx context.Context, executor db.RedisExecutor, command string, args ...any) ([]string, error) {
|
||||
value, err := redisDo(ctx, executor, command, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return redisStringSlice(value)
|
||||
}
|
||||
|
||||
func redisDo(ctx context.Context, executor db.RedisExecutor, command string, args ...any) (any, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if executor == nil {
|
||||
return nil, ErrNilRedisStoreExecutor
|
||||
}
|
||||
return executor.Do(ctx, command, args...)
|
||||
}
|
||||
|
||||
func (s *RedisSanctionStore) ready() (db.RedisExecutor, string, func() time.Time, error) {
|
||||
if s == nil || s.executor == nil {
|
||||
return nil, "", nil, ErrNilRedisStoreExecutor
|
||||
}
|
||||
now := s.now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return s.executor, normSanctionPref(s.prefix), now, nil
|
||||
}
|
||||
|
||||
func normSanctionPref(prefix string) string {
|
||||
prefix = strings.Trim(strings.TrimSpace(prefix), ":")
|
||||
if prefix == "" {
|
||||
return defRedisSanctionPref
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
func sanctionItemsKey(prefix string) string {
|
||||
return prefix + ":sanctions"
|
||||
}
|
||||
|
||||
func sanctionIndexKey(prefix string) string {
|
||||
return prefix + ":sanction_index"
|
||||
}
|
||||
|
||||
func sanctionSubjectKey(prefix, subject string) string {
|
||||
return prefix + ":subject:" + normalizeToken(subject)
|
||||
}
|
||||
|
||||
func redisStringSlice(value any) ([]string, error) {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case []string:
|
||||
return append([]string(nil), v...), nil
|
||||
case [][]byte:
|
||||
out := make([]string, len(v))
|
||||
for i := range v {
|
||||
out[i] = string(v[i])
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
case []any:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
switch x := item.(type) {
|
||||
case string:
|
||||
out = append(out, x)
|
||||
case []byte:
|
||||
out = append(out, string(x))
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected redis string item %T", item)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected redis string slice type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func redisInt64Value(value any, err error) (int64, error) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return int64(v), nil
|
||||
case int64:
|
||||
return v, nil
|
||||
case uint64:
|
||||
const maxInt64 = uint64(1<<63 - 1)
|
||||
if v > maxInt64 {
|
||||
return 0, fmt.Errorf("redis integer overflows int64: %d", v)
|
||||
}
|
||||
return int64(v), nil
|
||||
case []byte:
|
||||
return strconv.ParseInt(string(v), 10, 64)
|
||||
case string:
|
||||
return strconv.ParseInt(v, 10, 64)
|
||||
default:
|
||||
return 0, fmt.Errorf("unexpected redis integer type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func redisBulkBytes(value any) ([]byte, bool, error) {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return nil, false, nil
|
||||
case []byte:
|
||||
return append([]byte(nil), v...), true, nil
|
||||
case string:
|
||||
return []byte(v), true, nil
|
||||
default:
|
||||
return nil, false, fmt.Errorf("unexpected redis bulk type %T", value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidSanction = errors.New("moderation sanction is invalid")
|
||||
ErrSanctionMissing = errors.New("moderation sanction not found")
|
||||
)
|
||||
|
||||
type SanctionKind string
|
||||
|
||||
const (
|
||||
SanctionMute SanctionKind = "mute"
|
||||
SanctionBan SanctionKind = "ban"
|
||||
)
|
||||
|
||||
type Sanction struct {
|
||||
ID string `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Kind SanctionKind `json:"kind"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Until time.Time `json:"until,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type SanctionQuery struct {
|
||||
Subject string `json:"subject"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Kinds []SanctionKind `json:"kinds,omitempty"`
|
||||
Now time.Time `json:"now,omitempty"`
|
||||
}
|
||||
|
||||
type SanctionSnapshot struct {
|
||||
Items []Sanction `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type SanctionStore interface {
|
||||
Upsert(context.Context, Sanction) (Sanction, error)
|
||||
Remove(context.Context, string) error
|
||||
Active(context.Context, SanctionQuery) ([]Sanction, error)
|
||||
Snapshot(context.Context) (SanctionSnapshot, error)
|
||||
}
|
||||
|
||||
type MemorySanctionStore struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]Sanction
|
||||
bySubject map[string]map[string]struct{}
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewMemorySanctionStore() *MemorySanctionStore {
|
||||
return &MemorySanctionStore{
|
||||
items: make(map[string]Sanction),
|
||||
bySubject: make(map[string]map[string]struct{}),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemorySanctionStore) SetNow(now func() time.Time) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.now = now
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *MemorySanctionStore) Upsert(ctx context.Context, sanction Sanction) (Sanction, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Sanction{}, err
|
||||
}
|
||||
if s == nil {
|
||||
return Sanction{}, ErrSanctionMissing
|
||||
}
|
||||
item, err := normalizeSanction(sanction, s.nowUTC())
|
||||
if err != nil {
|
||||
return Sanction{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.items == nil {
|
||||
s.items = make(map[string]Sanction)
|
||||
}
|
||||
if s.bySubject == nil {
|
||||
s.bySubject = make(map[string]map[string]struct{})
|
||||
}
|
||||
if existing, ok := s.items[item.ID]; ok && existing.Subject != item.Subject {
|
||||
s.removeSubjectIdx(existing.Subject, item.ID)
|
||||
}
|
||||
s.items[item.ID] = cloneSanction(item)
|
||||
s.addSubjectIndex(item.Subject, item.ID)
|
||||
s.mu.Unlock()
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *MemorySanctionStore) Remove(ctx context.Context, id string) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil {
|
||||
return ErrSanctionMissing
|
||||
}
|
||||
id = normalizeToken(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("%w: id is required", ErrInvalidSanction)
|
||||
}
|
||||
s.mu.Lock()
|
||||
if _, ok := s.items[id]; !ok {
|
||||
s.mu.Unlock()
|
||||
return ErrSanctionMissing
|
||||
}
|
||||
s.removeSubjectIdx(s.items[id].Subject, id)
|
||||
delete(s.items, id)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemorySanctionStore) Active(ctx context.Context, query SanctionQuery) ([]Sanction, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
query.Subject = normalizeToken(query.Subject)
|
||||
query.Scope = normalizeToken(query.Scope)
|
||||
if query.Now.IsZero() {
|
||||
query.Now = s.nowUTC()
|
||||
} else {
|
||||
query.Now = query.Now.UTC()
|
||||
}
|
||||
kindSet := sanctionKindSet(query.Kinds)
|
||||
|
||||
s.mu.RLock()
|
||||
out := make([]Sanction, 0)
|
||||
ids := make([]string, 0, len(s.bySubject[query.Subject]))
|
||||
for id := range s.bySubject[query.Subject] {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
item, ok := s.items[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !sanctionScopeApplies(item.Scope, query.Scope) {
|
||||
continue
|
||||
}
|
||||
if len(kindSet) > 0 {
|
||||
if _, ok := kindSet[item.Kind]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !item.ActiveAt(query.Now) {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneSanction(item))
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sortSanctions(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MemorySanctionStore) Snapshot(ctx context.Context) (SanctionSnapshot, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return SanctionSnapshot{}, err
|
||||
}
|
||||
if s == nil {
|
||||
return SanctionSnapshot{}, nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
items := make([]Sanction, 0, len(s.items))
|
||||
for _, item := range s.items {
|
||||
items = append(items, cloneSanction(item))
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
sortSanctions(items)
|
||||
return SanctionSnapshot{Items: items}, nil
|
||||
}
|
||||
|
||||
func (s *MemorySanctionStore) addSubjectIndex(subject, id string) {
|
||||
if s.bySubject[subject] == nil {
|
||||
s.bySubject[subject] = make(map[string]struct{})
|
||||
}
|
||||
s.bySubject[subject][id] = struct{}{}
|
||||
}
|
||||
|
||||
func (s *MemorySanctionStore) removeSubjectIdx(subject, id string) {
|
||||
if ids := s.bySubject[subject]; ids != nil {
|
||||
delete(ids, id)
|
||||
if len(ids) == 0 {
|
||||
delete(s.bySubject, subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s Sanction) ActiveAt(now time.Time) bool {
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
return s.Until.IsZero() || s.Until.After(now)
|
||||
}
|
||||
|
||||
func normalizeSanction(sanction Sanction, now time.Time) (Sanction, error) {
|
||||
sanction.ID = normalizeToken(sanction.ID)
|
||||
sanction.Subject = normalizeToken(sanction.Subject)
|
||||
sanction.Scope = normalizeToken(sanction.Scope)
|
||||
sanction.Reason = strings.TrimSpace(sanction.Reason)
|
||||
sanction.Source = strings.TrimSpace(sanction.Source)
|
||||
sanction.Meta = cloneStringMap(sanction.Meta)
|
||||
if sanction.Subject == "" {
|
||||
return Sanction{}, fmt.Errorf("%w: subject is required", ErrInvalidSanction)
|
||||
}
|
||||
if sanction.Kind != SanctionMute && sanction.Kind != SanctionBan {
|
||||
return Sanction{}, fmt.Errorf("%w: invalid kind %q", ErrInvalidSanction, sanction.Kind)
|
||||
}
|
||||
if sanction.ID == "" {
|
||||
if hasDefaultIDDelim(sanction.Subject) || hasDefaultIDDelim(sanction.Scope) {
|
||||
return Sanction{}, fmt.Errorf("%w: subject or scope contains reserved delimiter", ErrInvalidSanction)
|
||||
}
|
||||
sanction.ID = sanction.Subject + ":" + string(sanction.Kind) + ":" + sanction.Scope
|
||||
}
|
||||
if sanction.CreatedAt.IsZero() {
|
||||
sanction.CreatedAt = now.UTC()
|
||||
} else {
|
||||
sanction.CreatedAt = sanction.CreatedAt.UTC()
|
||||
}
|
||||
if !sanction.Until.IsZero() {
|
||||
sanction.Until = sanction.Until.UTC()
|
||||
}
|
||||
return sanction, nil
|
||||
}
|
||||
|
||||
func sanctionKindSet(kinds []SanctionKind) map[SanctionKind]struct{} {
|
||||
if len(kinds) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[SanctionKind]struct{}, len(kinds))
|
||||
for _, kind := range kinds {
|
||||
if kind == "" {
|
||||
continue
|
||||
}
|
||||
out[kind] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sanctionScopeApplies(sanctionScope, requestScope string) bool {
|
||||
sanctionScope = normalizeToken(sanctionScope)
|
||||
requestScope = normalizeToken(requestScope)
|
||||
return sanctionScope == "" || sanctionScope == "global" || sanctionScope == requestScope
|
||||
}
|
||||
|
||||
// hasDefaultIDDelim 拦截会让默认处罚 ID 产生歧义的保留分隔符。
|
||||
func hasDefaultIDDelim(value string) bool {
|
||||
return strings.Contains(value, ":") || strings.ContainsRune(value, '\x00')
|
||||
}
|
||||
|
||||
// encodeModerationKey 用长度前缀拼接多段键,避免字段内容自带分隔符时发生碰撞。
|
||||
func encodeModerationKey(parts ...string) string {
|
||||
var builder strings.Builder
|
||||
for _, part := range parts {
|
||||
builder.WriteString(strconv.Itoa(len(part)))
|
||||
builder.WriteByte(':')
|
||||
builder.WriteString(part)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func sortSanctions(items []Sanction) {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Subject != items[j].Subject {
|
||||
return items[i].Subject < items[j].Subject
|
||||
}
|
||||
if items[i].Scope != items[j].Scope {
|
||||
return items[i].Scope < items[j].Scope
|
||||
}
|
||||
if items[i].Kind != items[j].Kind {
|
||||
return items[i].Kind < items[j].Kind
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
}
|
||||
|
||||
func cloneSanction(sanction Sanction) Sanction {
|
||||
sanction.Meta = cloneStringMap(sanction.Meta)
|
||||
return sanction
|
||||
}
|
||||
|
||||
func (s *MemorySanctionStore) nowUTC() time.Time {
|
||||
s.mu.RLock()
|
||||
now := s.now
|
||||
s.mu.RUnlock()
|
||||
if now == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return now().UTC()
|
||||
}
|
||||
|
||||
func ctxErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package moderation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNilSQLSanctionDB = errors.New("moderation sql db is required")
|
||||
ErrInvalidSanctionTable = errors.New("moderation sanction table name is invalid")
|
||||
ErrUnsupportedSQLSanction = errors.New("moderation sql operation is unsupported")
|
||||
ErrSQLSanctionRows = errors.New("moderation sql rows are required")
|
||||
)
|
||||
|
||||
const defaultSanctionTable = "moderation_sanctions"
|
||||
|
||||
type SQLSanctionRows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Close() error
|
||||
Err() error
|
||||
}
|
||||
|
||||
type SQLSanctionDB interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
QueryContext(context.Context, string, ...any) (SQLSanctionRows, error)
|
||||
QueryRowContext(context.Context, string, ...any) SQLSanctionRow
|
||||
}
|
||||
|
||||
type SQLSanctionRow interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
type SQLSanctionDBAdapter struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func (a SQLSanctionDBAdapter) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||
if a.DB == nil {
|
||||
return nil, ErrNilSQLSanctionDB
|
||||
}
|
||||
return a.DB.ExecContext(ctx, query, args...) // #nosec G701 -- 适配层只执行本包已构造的 SQL,业务值仍由调用方参数绑定。
|
||||
}
|
||||
|
||||
func (a SQLSanctionDBAdapter) QueryContext(ctx context.Context, query string, args ...any) (SQLSanctionRows, error) {
|
||||
if a.DB == nil {
|
||||
return nil, ErrNilSQLSanctionDB
|
||||
}
|
||||
// #nosec G701 -- SQL 由本包白名单表名构造,业务值仍由调用方参数绑定。
|
||||
return a.DB.QueryContext(ctx, query, args...) //nolint:rowserrcheck // 适配层只返回 rows,调用方负责遍历结束后的 Err 检查。
|
||||
}
|
||||
|
||||
func (a SQLSanctionDBAdapter) QueryRowContext(ctx context.Context, query string, args ...any) SQLSanctionRow {
|
||||
if a.DB == nil {
|
||||
return errSQLRow{err: ErrNilSQLSanctionDB}
|
||||
}
|
||||
return a.DB.QueryRowContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
type SQLSanctionStoreOptions struct {
|
||||
Table string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type SQLSanctionStore struct {
|
||||
db SQLSanctionDB
|
||||
table string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewSQLSanctionStore(db SQLSanctionDB, options SQLSanctionStoreOptions) (*SQLSanctionStore, error) {
|
||||
if db == nil {
|
||||
return nil, ErrNilSQLSanctionDB
|
||||
}
|
||||
table, err := normSanctionTable(options.Table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &SQLSanctionStore{db: db, table: table, now: now}, nil
|
||||
}
|
||||
|
||||
func NewSQLSanctionStoreFromDB(db *sql.DB, options SQLSanctionStoreOptions) (*SQLSanctionStore, error) {
|
||||
if db == nil {
|
||||
return nil, ErrNilSQLSanctionDB
|
||||
}
|
||||
return NewSQLSanctionStore(SQLSanctionDBAdapter{DB: db}, options)
|
||||
}
|
||||
|
||||
func (s *SQLSanctionStore) EnsureSchema(ctx context.Context) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return ErrNilSQLSanctionDB
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, s.Schema())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLSanctionStore) Schema() string {
|
||||
table := quoteSanctionID(s.table)
|
||||
return fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
id VARCHAR(191) NOT NULL,
|
||||
subject VARCHAR(191) NOT NULL,
|
||||
scope VARCHAR(64) NOT NULL DEFAULT '',
|
||||
kind VARCHAR(32) NOT NULL,
|
||||
reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||
source VARCHAR(128) NOT NULL DEFAULT '',
|
||||
until_at DATETIME(6) NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
sanction_json JSON NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_moderation_sanctions_subject_scope (subject, scope, kind),
|
||||
KEY idx_moderation_sanctions_until (until_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, table)
|
||||
}
|
||||
|
||||
func (s *SQLSanctionStore) Upsert(ctx context.Context, sanction Sanction) (Sanction, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Sanction{}, err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return Sanction{}, ErrNilSQLSanctionDB
|
||||
}
|
||||
item, err := normalizeSanction(sanction, s.nowUTC())
|
||||
if err != nil {
|
||||
return Sanction{}, err
|
||||
}
|
||||
data, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return Sanction{}, err
|
||||
}
|
||||
until := nullableTime(item.Until)
|
||||
_, err = s.db.ExecContext(ctx,
|
||||
"INSERT INTO "+quoteSanctionID(s.table)+" (id, subject, scope, kind, reason, source, until_at, created_at, sanction_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE subject=VALUES(subject), scope=VALUES(scope), kind=VALUES(kind), reason=VALUES(reason), source=VALUES(source), until_at=VALUES(until_at), sanction_json=VALUES(sanction_json)",
|
||||
item.ID,
|
||||
item.Subject,
|
||||
item.Scope,
|
||||
string(item.Kind),
|
||||
item.Reason,
|
||||
item.Source,
|
||||
until,
|
||||
item.CreatedAt,
|
||||
string(data),
|
||||
)
|
||||
if err != nil {
|
||||
return Sanction{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *SQLSanctionStore) Remove(ctx context.Context, id string) error {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return ErrNilSQLSanctionDB
|
||||
}
|
||||
id = normalizeToken(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("%w: id is required", ErrInvalidSanction)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, "DELETE FROM "+quoteSanctionID(s.table)+" WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result != nil {
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrSanctionMissing
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLSanctionStore) Active(ctx context.Context, query SanctionQuery) ([]Sanction, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return nil, ErrNilSQLSanctionDB
|
||||
}
|
||||
query.Subject = normalizeToken(query.Subject)
|
||||
query.Scope = normalizeToken(query.Scope)
|
||||
if query.Subject == "" {
|
||||
return nil, fmt.Errorf("%w: subject is required", ErrInvalidSanction)
|
||||
}
|
||||
if query.Now.IsZero() {
|
||||
query.Now = s.nowUTC()
|
||||
} else {
|
||||
query.Now = query.Now.UTC()
|
||||
}
|
||||
sqlText := "SELECT sanction_json FROM " + quoteSanctionID(s.table) + " WHERE subject = ? AND (scope = '' OR scope = 'global' OR scope = ?) AND (until_at IS NULL OR until_at > ?)"
|
||||
args := []any{query.Subject, query.Scope, query.Now}
|
||||
if len(query.Kinds) > 0 {
|
||||
placeholders := make([]string, 0, len(query.Kinds))
|
||||
for _, kind := range query.Kinds {
|
||||
if kind == "" {
|
||||
continue
|
||||
}
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, string(kind))
|
||||
}
|
||||
if len(placeholders) > 0 {
|
||||
sqlText += " AND kind IN (" + strings.Join(placeholders, ",") + ")"
|
||||
}
|
||||
}
|
||||
sqlText += " ORDER BY subject, scope, kind, id"
|
||||
rows, err := s.db.QueryContext(ctx, sqlText, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rows == nil {
|
||||
return nil, ErrSQLSanctionRows
|
||||
}
|
||||
items, err := scanSanctionRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Sanction, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.ActiveAt(query.Now) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
sortSanctions(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *SQLSanctionStore) Snapshot(ctx context.Context) (SanctionSnapshot, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return SanctionSnapshot{}, err
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return SanctionSnapshot{}, ErrNilSQLSanctionDB
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT sanction_json FROM "+quoteSanctionID(s.table)+" ORDER BY subject, scope, kind, id")
|
||||
if err != nil {
|
||||
return SanctionSnapshot{}, err
|
||||
}
|
||||
if rows == nil {
|
||||
return SanctionSnapshot{}, ErrSQLSanctionRows
|
||||
}
|
||||
items, err := scanSanctionRows(rows)
|
||||
if err != nil {
|
||||
return SanctionSnapshot{}, err
|
||||
}
|
||||
sortSanctions(items)
|
||||
return SanctionSnapshot{Items: items}, nil
|
||||
}
|
||||
|
||||
func scanSanctionRows(rows SQLSanctionRows) (items []Sanction, err error) {
|
||||
if rows == nil {
|
||||
return nil, ErrSQLSanctionRows
|
||||
}
|
||||
defer closeSanctionRowsErr(rows, &err)
|
||||
items = make([]Sanction, 0)
|
||||
for rows.Next() {
|
||||
var data []byte
|
||||
if err := rows.Scan(&data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var item Sanction
|
||||
if err := json.Unmarshal(data, &item); err != nil {
|
||||
return nil, fmt.Errorf("decode moderation sql sanction: %w", err)
|
||||
}
|
||||
items = append(items, cloneSanction(item))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func closeSanctionRowsErr(rows SQLSanctionRows, err *error) {
|
||||
if rows == nil || err == nil {
|
||||
return
|
||||
}
|
||||
if closeErr := rows.Close(); closeErr != nil && *err == nil {
|
||||
*err = closeErr
|
||||
}
|
||||
}
|
||||
|
||||
func normSanctionTable(table string) (string, error) {
|
||||
table = strings.TrimSpace(table)
|
||||
if table == "" {
|
||||
table = defaultSanctionTable
|
||||
}
|
||||
if !sanctionTablePattern.MatchString(table) {
|
||||
return "", ErrInvalidSanctionTable
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
func quoteSanctionID(identifier string) string {
|
||||
return "`" + identifier + "`"
|
||||
}
|
||||
|
||||
func nullableTime(value time.Time) any {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return value.UTC()
|
||||
}
|
||||
|
||||
func (s *SQLSanctionStore) nowUTC() time.Time {
|
||||
if s == nil || s.now == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return s.now().UTC()
|
||||
}
|
||||
|
||||
type errSQLRow struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r errSQLRow) Scan(...any) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
var sanctionTablePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
Reference in new issue
Block a user