落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
138 lines
3.5 KiB
Go
138 lines
3.5 KiB
Go
package home
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"regexp"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
|
|
"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 and daily tips.
|
|
type Service struct {
|
|
Repo *repository.HomeToolsRepo
|
|
Tips *repository.HomeDailyTipsRepo
|
|
Profiles *repository.ProfileRepo
|
|
LLM *deepseek.Client
|
|
}
|
|
|
|
// 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
|
|
}
|