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:
jackyu66git
2026-08-13 01:27:58 +08:00
co-authored by Cursor
parent 13860bf1ef
commit 89756f65b4
227 changed files with 7405 additions and 1407 deletions
+184
View File
@@ -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)
}
}