feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package textsafe_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
)
|
||||
|
||||
func TestOK(t *testing.T) {
|
||||
got, err := textsafe.Check(textsafe.Nickname, " 心语岛 ")
|
||||
if err != nil || got != "心语岛" {
|
||||
t.Fatalf("got %q err=%v", got, err)
|
||||
}
|
||||
got, err = textsafe.Check(textsafe.DisplayName, "小愈")
|
||||
if err != nil || got != "小愈" {
|
||||
t.Fatalf("got %q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanned(t *testing.T) {
|
||||
if _, err := textsafe.Check(textsafe.Nickname, "算命达人"); err == nil {
|
||||
t.Fatal("expected reject")
|
||||
}
|
||||
if _, err := textsafe.Check(textsafe.AskContent, "帮我占卜一下"); err == nil {
|
||||
t.Fatal("expected reject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXSS(t *testing.T) {
|
||||
if _, err := textsafe.Check(textsafe.AskContent, `hi <script>alert(1)</script>`); err == nil {
|
||||
t.Fatal("expected reject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpam(t *testing.T) {
|
||||
if _, err := textsafe.Check(textsafe.Note, "啊啊啊啊啊啊啊啊"); err == nil {
|
||||
t.Fatal("expected reject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLength(t *testing.T) {
|
||||
long := strings.Repeat("字", 20)
|
||||
if _, err := textsafe.Check(textsafe.Nickname, long); err == nil {
|
||||
t.Fatal("expected reject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalEmpty(t *testing.T) {
|
||||
got, err := textsafe.Check(textsafe.Note, " ")
|
||||
if err != nil || got != "" {
|
||||
t.Fatalf("got %q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user