feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidTools = errors.New("invalid home tools")
|
||||
ErrTooManyTools = errors.New("too many home tools")
|
||||
)
|
||||
|
||||
var pathRe = regexp.MustCompile(`^/[a-zA-Z0-9_./-]{1,120}$`)
|
||||
|
||||
var allowedIcons = map[string]struct{}{
|
||||
"mbti": {}, "star": {}, "portrait": {}, "rhythm": {}, "synastry": {}, "astro": {},
|
||||
"companion": {}, "ask": {}, "cards": {}, "reports": {}, "growth": {}, "relation": {},
|
||||
}
|
||||
|
||||
// Service serves homepage tool catalog.
|
||||
type Service struct {
|
||||
Repo *repository.HomeToolsRepo
|
||||
}
|
||||
|
||||
// ListPublic returns enabled tools.
|
||||
func (s *Service) ListPublic(ctx context.Context) ([]repository.HomeTool, error) {
|
||||
return s.Repo.ListEnabled(ctx)
|
||||
}
|
||||
|
||||
// ListAdmin returns all tools.
|
||||
func (s *Service) ListAdmin(ctx context.Context) ([]repository.HomeTool, error) {
|
||||
return s.Repo.ListAll(ctx)
|
||||
}
|
||||
|
||||
// ReplaceInput is one tool in a PUT body (id optional).
|
||||
type ReplaceInput struct {
|
||||
ID string `json:"id"`
|
||||
RowIndex int `json:"row_index"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Path string `json:"path"`
|
||||
Icon string `json:"icon"`
|
||||
Label string `json:"label"`
|
||||
Badge *string `json:"badge"`
|
||||
BadgeTone *string `json:"badge_tone"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Replace validates and replaces all tools.
|
||||
func (s *Service) Replace(ctx context.Context, items []ReplaceInput) error {
|
||||
return s.ReplaceWithAudit(ctx, uuid.Nil, items, nil)
|
||||
}
|
||||
|
||||
// ReplaceWithAudit validates, replaces, and audits in one transaction when adminID set.
|
||||
func (s *Service) ReplaceWithAudit(
|
||||
ctx context.Context,
|
||||
adminID uuid.UUID,
|
||||
items []ReplaceInput,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
if len(items) == 0 {
|
||||
return ErrInvalidTools
|
||||
}
|
||||
if len(items) > 24 {
|
||||
return ErrTooManyTools
|
||||
}
|
||||
rows := make([]repository.HomeTool, 0, len(items))
|
||||
for _, in := range items {
|
||||
t, err := normalizeTool(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows = append(rows, *t)
|
||||
}
|
||||
return s.Repo.ReplaceAllWithAudit(ctx, rows, adminID, meta)
|
||||
}
|
||||
|
||||
func normalizeTool(in ReplaceInput) (*repository.HomeTool, error) {
|
||||
if in.RowIndex != 1 && in.RowIndex != 2 {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
path := strings.TrimSpace(in.Path)
|
||||
if !pathRe.MatchString(path) || strings.Contains(path, "..") {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
icon := strings.TrimSpace(in.Icon)
|
||||
if _, ok := allowedIcons[icon]; !ok {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
label := strings.TrimSpace(in.Label)
|
||||
n := utf8.RuneCountInString(label)
|
||||
if n < 1 || n > 16 {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
var badge, tone *string
|
||||
if in.Badge != nil {
|
||||
b := strings.TrimSpace(*in.Badge)
|
||||
if b != "" {
|
||||
if utf8.RuneCountInString(b) > 4 {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
badge = &b
|
||||
}
|
||||
}
|
||||
if in.BadgeTone != nil {
|
||||
t := strings.TrimSpace(*in.BadgeTone)
|
||||
if t != "" && t != "hot" && t != "new" {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
if t != "" {
|
||||
tone = &t
|
||||
}
|
||||
}
|
||||
id := uuid.Nil
|
||||
if strings.TrimSpace(in.ID) != "" {
|
||||
parsed, err := uuid.Parse(in.ID)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidTools
|
||||
}
|
||||
id = parsed
|
||||
}
|
||||
return &repository.HomeTool{
|
||||
ID: id, RowIndex: in.RowIndex, SortOrder: in.SortOrder,
|
||||
Path: path, Icon: icon, Label: label, Badge: badge, BadgeTone: tone, Enabled: in.Enabled,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package home
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeToolOK(t *testing.T) {
|
||||
b := "热"
|
||||
tone := "hot"
|
||||
got, err := normalizeTool(ReplaceInput{
|
||||
RowIndex: 1, SortOrder: 1, Path: "/portrait", Icon: "portrait", Label: "愈心解码",
|
||||
Badge: &b, BadgeTone: &tone, Enabled: true,
|
||||
})
|
||||
if err != nil || got.Label != "愈心解码" {
|
||||
t.Fatalf("got=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeToolRejects(t *testing.T) {
|
||||
cases := []ReplaceInput{
|
||||
{RowIndex: 3, Path: "/a", Icon: "ask", Label: "x"},
|
||||
{RowIndex: 1, Path: "https://evil.com", Icon: "ask", Label: "x"},
|
||||
{RowIndex: 1, Path: "/../etc", Icon: "ask", Label: "x"},
|
||||
{RowIndex: 1, Path: "/ask", Icon: "nope", Label: "x"},
|
||||
{RowIndex: 1, Path: "/ask", Icon: "ask", Label: ""},
|
||||
}
|
||||
for i, c := range cases {
|
||||
if _, err := normalizeTool(c); err != ErrInvalidTools {
|
||||
t.Fatalf("case %d want ErrInvalidTools got %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user