feat: add ads/sponsor/captcha modules and polish home & chat UI

This commit is contained in:
2026-09-23 04:39:58 +08:00
parent 96314893bb
commit c1f6a6636b
27 changed files with 4278 additions and 121 deletions

198
backend/service/sponsor.go Normal file
View File

@@ -0,0 +1,198 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
const (
SettingKeySponsorsConfig = "sponsors_config"
SponsorDefaultTitle = "赞助商"
SponsorMaxItems = 24
SponsorMaxNameRunes = 32
SponsorMaxHoverRunes = 120
)
// SponsorItem 侧栏赞助商条目
type SponsorItem struct {
ID string `json:"id"`
Name string `json:"name"`
LogoURL string `json:"logo_url"`
LinkURL string `json:"link_url"`
HoverText string `json:"hover_text"`
Enabled bool `json:"enabled"`
SortOrder int `json:"sort_order"`
}
// SponsorsConfig 赞助商板块配置
type SponsorsConfig struct {
Enabled bool `json:"enabled"`
PanelTitle string `json:"panel_title"`
Items []SponsorItem `json:"items"`
}
func DefaultSponsorsConfig() SponsorsConfig {
return SponsorsConfig{
Enabled: true,
PanelTitle: SponsorDefaultTitle,
Items: []SponsorItem{},
}
}
func (s *AdService) loadSponsorsConfig() (SponsorsConfig, error) {
var row model.SiteSetting
err := s.db.Where("key = ?", SettingKeySponsorsConfig).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return DefaultSponsorsConfig(), nil
}
if err != nil {
return SponsorsConfig{}, err
}
cfg := DefaultSponsorsConfig()
if strings.TrimSpace(row.Value) == "" {
return cfg, nil
}
if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
return DefaultSponsorsConfig(), nil
}
if strings.TrimSpace(cfg.PanelTitle) == "" {
cfg.PanelTitle = SponsorDefaultTitle
}
return cfg, nil
}
func (s *AdService) GetSponsorsConfig() (SponsorsConfig, error) {
return s.loadSponsorsConfig()
}
// PublicSponsors 侧栏展示用(仅启用且有 logo 的条目)
func (s *AdService) PublicSponsors() (SponsorsConfig, []SponsorItem, error) {
cfg, err := s.loadSponsorsConfig()
if err != nil {
return cfg, nil, err
}
if !cfg.Enabled {
return cfg, []SponsorItem{}, nil
}
out := make([]SponsorItem, 0, len(cfg.Items))
for _, it := range cfg.Items {
if !it.Enabled {
continue
}
if strings.TrimSpace(it.LogoURL) == "" {
continue
}
out = append(out, it)
}
return cfg, out, nil
}
func (s *AdService) SaveSponsorsConfig(in SponsorsConfig) (SponsorsConfig, error) {
out, err := normalizeSponsorsConfig(in)
if err != nil {
return SponsorsConfig{}, err
}
b, err := json.Marshal(out)
if err != nil {
return SponsorsConfig{}, err
}
row := model.SiteSetting{Key: SettingKeySponsorsConfig, Value: string(b), UpdatedAt: time.Now()}
if err := s.db.Save(&row).Error; err != nil {
return SponsorsConfig{}, err
}
return out, nil
}
func normalizeSponsorsConfig(in SponsorsConfig) (SponsorsConfig, error) {
out := DefaultSponsorsConfig()
out.Enabled = in.Enabled
title := strings.TrimSpace(in.PanelTitle)
if title == "" {
title = SponsorDefaultTitle
}
if utf8.RuneCountInString(title) > 16 {
return SponsorsConfig{}, errors.New("侧栏标题最多 16 字")
}
out.PanelTitle = title
if len(in.Items) > SponsorMaxItems {
return SponsorsConfig{}, fmt.Errorf("赞助商最多 %d 个", SponsorMaxItems)
}
seen := map[string]struct{}{}
items := make([]SponsorItem, 0, len(in.Items))
for i, raw := range in.Items {
id := strings.TrimSpace(raw.ID)
if id == "" {
id = fmt.Sprintf("sp_%d", i+1)
}
if _, ok := seen[id]; ok {
return SponsorsConfig{}, errors.New("赞助商 id 重复")
}
seen[id] = struct{}{}
name := strings.TrimSpace(raw.Name)
if name == "" {
return SponsorsConfig{}, errors.New("请填写赞助商名称")
}
if utf8.RuneCountInString(name) > SponsorMaxNameRunes {
return SponsorsConfig{}, fmt.Errorf("赞助商名称最多 %d 字", SponsorMaxNameRunes)
}
logo := strings.TrimSpace(raw.LogoURL)
if logo == "" {
return SponsorsConfig{}, errors.New("请上传或填写赞助商 logo")
}
if !validSponsorLogoURL(logo) {
return SponsorsConfig{}, errors.New("logo 须为 https 或本站 /uploads/ 图片")
}
link := strings.TrimSpace(raw.LinkURL)
if link != "" {
u, err := url.Parse(link)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
return SponsorsConfig{}, errors.New("赞助商链接须为 http(s) 地址")
}
link = u.String()
}
hover := strings.TrimSpace(raw.HoverText)
if utf8.RuneCountInString(hover) > SponsorMaxHoverRunes {
return SponsorsConfig{}, fmt.Errorf("赞助说明最多 %d 字", SponsorMaxHoverRunes)
}
items = append(items, SponsorItem{
ID: id,
Name: name,
LogoURL: logo,
LinkURL: link,
HoverText: hover,
Enabled: raw.Enabled,
SortOrder: i,
})
}
out.Items = items
return out, nil
}
func validSponsorLogoURL(u string) bool {
if adImageUploadRe.MatchString(u) {
return true
}
if strings.HasPrefix(strings.ToLower(u), "https://") && adImageHTTPSRe.MatchString(u) {
return true
}
// 允许常见本站 uploads 路径(赞助 logo)
low := strings.ToLower(u)
if strings.HasPrefix(low, "/uploads/") && len(u) <= 500 {
return true
}
return false
}