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>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidBanner = errors.New("invalid banner")
|
||||
ErrBannerConflict = errors.New("banner code conflict")
|
||||
bannerPathRe = regexp.MustCompile(`^/[a-zA-Z0-9_./-]{1,200}$`)
|
||||
bannerPlacements = map[string]struct{}{"home": {}, "explore": {}, "ask": {}}
|
||||
bannerCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`)
|
||||
)
|
||||
|
||||
// BannerWriteBody is JSON for create/update.
|
||||
type BannerWriteBody struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Placement string `json:"placement"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
LinkPath *string `json:"link_path"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// CreateBanner validates, inserts, audits.
|
||||
func (s *Service) CreateBanner(ctx context.Context, adminID uuid.UUID, body BannerWriteBody) (*repository.BannerRow, error) {
|
||||
in, err := normalizeBannerWrite(body, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
|
||||
row, err := s.Repo.CreateBannerWithAudit(ctx, adminID, in, meta)
|
||||
if repository.BannerCodeConflict(err) {
|
||||
return nil, ErrBannerConflict
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// UpdateBanner validates, updates, audits.
|
||||
func (s *Service) UpdateBanner(ctx context.Context, adminID, id uuid.UUID, body BannerWriteBody) (*repository.BannerRow, error) {
|
||||
in, err := normalizeBannerWrite(body, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"code": in.Code, "active": in.Active})
|
||||
row, err := s.Repo.UpdateBannerWithAudit(ctx, adminID, id, in, meta)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrBannerNotFound
|
||||
}
|
||||
if repository.BannerCodeConflict(err) {
|
||||
return nil, ErrBannerConflict
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
func normalizeBannerWrite(body BannerWriteBody, requireCode bool) (repository.BannerWriteInput, error) {
|
||||
code := strings.TrimSpace(body.Code)
|
||||
title := strings.TrimSpace(body.Title)
|
||||
placement := strings.TrimSpace(body.Placement)
|
||||
if requireCode && code == "" {
|
||||
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||
}
|
||||
if code != "" && !bannerCodeRe.MatchString(code) {
|
||||
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||
}
|
||||
if title == "" || utf8.RuneCountInString(title) > 128 {
|
||||
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||
}
|
||||
if _, ok := bannerPlacements[placement]; !ok {
|
||||
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||
}
|
||||
var link *string
|
||||
if body.LinkPath != nil {
|
||||
lp := strings.TrimSpace(*body.LinkPath)
|
||||
if lp == "" {
|
||||
link = nil
|
||||
} else {
|
||||
if strings.Contains(lp, "://") || !bannerPathRe.MatchString(lp) {
|
||||
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||
}
|
||||
link = &lp
|
||||
}
|
||||
}
|
||||
var img *string
|
||||
if body.ImageURL != nil {
|
||||
u := strings.TrimSpace(*body.ImageURL)
|
||||
if u == "" {
|
||||
img = nil
|
||||
} else if utf8.RuneCountInString(u) > 2000 {
|
||||
return repository.BannerWriteInput{}, ErrInvalidBanner
|
||||
} else {
|
||||
img = &u
|
||||
}
|
||||
}
|
||||
return repository.BannerWriteInput{
|
||||
Code: code, Title: title, Placement: placement,
|
||||
ImageURL: img, LinkPath: link, SortOrder: body.SortOrder, Active: body.Active,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user