DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+191
View File
@@ -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_]*$`)