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:
jackyu66git
2026-08-13 19:55:27 +08:00
co-authored by Cursor
parent ad45d17dff
commit 9707b72808
35 changed files with 1203 additions and 29 deletions
@@ -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
}
+2 -1
View File
@@ -31,6 +31,7 @@ const (
PermAIConfigRead = "admin.ai_config.read"
PermCrisisRead = "admin.crisis.read"
PermCMSRead = "admin.cms.read"
PermCMSWrite = "admin.cms.write"
PermPrivacyRead = "admin.privacy.read"
PermExploreRead = "admin.explore.read"
PermGrowthRead = "admin.growth.read"
@@ -43,7 +44,7 @@ var knownPermissions = map[string]struct{}{
PermUsersStatusWrite: {}, PermMembershipPlansRead: {}, PermMembershipPlansWrite: {},
PermMembershipCodesRead: {}, PermMembershipCodesWrite: {},
PermAskRead: {}, PermAskTranscriptRead: {}, PermAskFeedbackWrite: {}, PermContentSafetyRead: {},
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermGrowthRead: {}, PermExploreRead: {}, PermPrivacyRead: {},
PermAIConfigRead: {}, PermCrisisRead: {}, PermCMSRead: {}, PermCMSWrite: {}, PermGrowthRead: {}, PermExploreRead: {}, PermPrivacyRead: {},
}
var (
+19
View File
@@ -31,6 +31,7 @@ type Service struct {
Repo *repository.HomeToolsRepo
Tips *repository.HomeDailyTipsRepo
Profiles *repository.ProfileRepo
CMS *repository.AdminRepo
LLM *deepseek.Client
}
@@ -39,6 +40,24 @@ 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)