Files
digital-psychology/apps/api/internal/service/home/service.go
T
jackyu66gitandCursor 9707b72808 feat(ECR-041): OpsCMS Banner 薄写面(Write-Wave 首刀)
Admin POST/PUT + admin.cms.write/审计;C 端 GET /home/banners;首页投影回退静态 homeFeeds;migration 000051;不碰 FeedSlot/支付/UGC。

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

157 lines
4.0 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
CMS *repository.AdminRepo
LLM *deepseek.Client
}
// ListPublic returns enabled tools.
func (s *Service) ListPublic(ctx context.Context) ([]repository.HomeTool, error) {
return s.Repo.ListEnabled(ctx)
}
// ListPublicBanners returns active banners for placement (default home).
func (s *Service) ListPublicBanners(ctx context.Context, placement string) ([]repository.BannerRow, error) {
if placement == "" {
placement = "home"
}
if s.CMS == nil {
return []repository.BannerRow{}, nil
}
items, err := s.CMS.ListActiveBanners(ctx, placement)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.BannerRow{}
}
return items, nil
}
// 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
}