Files
digital-psychology/apps/api/internal/textsafe/textsafe.go
T
jackyu66gitandCursor 89756f65b4 feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:27:58 +08:00

185 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package textsafe validates user free-text at the API boundary (Spec input-compliance).
package textsafe
import (
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// Kind selects length limits and whether empty is allowed.
type Kind int
const (
Nickname Kind = iota
DisplayName
AskContent
Note // optional
Title
Focus // optional
Scene
)
// ErrRejected is returned for compliance failures (map to HTTP 400 / code 40060).
var ErrRejected = errors.New("文案不合规")
type limits struct {
min, max int
allowNL bool
}
func lim(k Kind) limits {
switch k {
case Nickname:
return limits{1, 16, false}
case DisplayName:
return limits{1, 24, false}
case AskContent:
return limits{1, 2000, true}
case Note:
return limits{0, 200, true}
case Title:
return limits{1, 40, false}
case Focus:
return limits{0, 40, false}
case Scene:
return limits{1, 80, false}
default:
return limits{1, 200, false}
}
}
var banned = []string{
"占卜", "算命", "改命", "测测",
"疗效", "包治", "根治", "治疗疾病", "治愈癌症",
"不测就有灾", "大师算卦", "神棍",
}
// Check normalizes and validates. Empty optional kinds return ("", nil).
func Check(kind Kind, raw string) (string, error) {
s := Normalize(raw, lim(kind).allowNL)
l := lim(kind)
n := utf8.RuneCountInString(s)
if n < l.min {
if l.min == 0 {
return "", nil
}
return "", reject("请填写内容")
}
if n > l.max {
return "", reject("内容过长")
}
if err := rejectControls(s, l.allowNL); err != nil {
return "", err
}
if err := rejectMarkup(s); err != nil {
return "", err
}
if err := rejectBanned(s); err != nil {
return "", err
}
if err := rejectSpam(s); err != nil {
return "", err
}
return s, nil
}
// Normalize trims; optionally keeps internal newlines.
func Normalize(s string, allowNL bool) string {
s = strings.TrimSpace(s)
if !allowNL {
s = strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
return r
}, s)
s = strings.Join(strings.Fields(s), " ")
}
return s
}
func reject(msg string) error {
return fmt.Errorf("%w%s", ErrRejected, msg)
}
func rejectControls(s string, allowNL bool) error {
for _, r := range s {
if r == 0 {
return reject("含非法字符")
}
if r == '\n' || r == '\r' || r == '\t' {
if !allowNL {
return reject("含非法空白")
}
continue
}
if r < 0x20 || r == 0x7f {
return reject("含非法字符")
}
}
return nil
}
func rejectMarkup(s string) error {
low := strings.ToLower(s)
if strings.Contains(low, "<script") || strings.Contains(low, "javascript:") {
return reject("含不安全内容")
}
if strings.Contains(low, "onerror=") || strings.Contains(low, "onload=") {
return reject("含不安全内容")
}
// bare tags like <img ...>
for i := 0; i < len(s); i++ {
if s[i] != '<' {
continue
}
if i+1 < len(s) {
c := s[i+1]
if c == '/' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '!' {
return reject("含不安全内容")
}
}
}
return nil
}
func rejectBanned(s string) error {
for _, b := range banned {
if b != "" && strings.Contains(s, b) {
return reject("含不允许的表述,请换一种说法")
}
}
return nil
}
func rejectSpam(s string) error {
runes := []rune(s)
if len(runes) >= 6 {
allSame := true
for i := 1; i < len(runes); i++ {
if runes[i] != runes[0] {
allSame = false
break
}
}
if allSame && !unicode.IsSpace(runes[0]) {
return reject("请勿重复刷屏")
}
}
streak := 1
for i := 1; i < len(runes); i++ {
if runes[i] == runes[i-1] && !unicode.IsSpace(runes[i]) {
streak++
if streak >= 8 {
return reject("请勿重复刷屏")
}
} else {
streak = 1
}
}
return nil
}