Files
jiang13-bbs/backend/service/ad.go
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

592 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"encoding/json"
"errors"
"net/mail"
"net/url"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
const (
SettingKeyAdsConfig = "ads_config"
AdMaxTitleRunes = 24
AdMaxNoteRunes = 100
AdMaxImageShow = 5
AdMaxTextShow = 6
AdMaxActiveShow = AdMaxImageShow + AdMaxTextShow
AdDefaultTitle = "自助推广"
)
var (
ErrAdNotFound = errors.New("广告不存在")
ErrAdForbidden = errors.New("无权操作")
ErrAdInvalid = errors.New("广告参数无效")
ErrAdCaptcha = errors.New("验证码错误或已过期")
ErrAdDisabled = errors.New("自助推广暂未开放")
ErrAdBadPayment = errors.New("请选择有效的支付方式")
ErrAdBadDuration = errors.New("请选择有效的投放时长")
hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6})$`)
adImageHTTPSRe = regexp.MustCompile(`(?i)^https://[^\s\\]{1,500}$`)
adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`)
)
// AdDurationOption 可购时长档位(图片 / 文字广告分别计价)
type AdDurationOption struct {
Days int `json:"days"`
Label string `json:"label"`
PriceHint string `json:"price_hint"` // 图片广告价格
PriceHintText string `json:"price_hint_text"` // 文字广告价格
}
// AdPaymentOption 收款方式(展示二维码,人工确认)
type AdPaymentOption struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"` // alipay | wechat | other
QRURL string `json:"qr_url"`
Hint string `json:"hint"`
Enabled bool `json:"enabled"`
}
// AdConfig 广告位运营配置
type AdConfig struct {
Enabled bool `json:"enabled"`
PanelTitle string `json:"panel_title"`
Durations []AdDurationOption `json:"durations"`
Payments []AdPaymentOption `json:"payments"`
}
func DefaultAdConfig() AdConfig {
return AdConfig{
Enabled: true,
PanelTitle: AdDefaultTitle,
Durations: []AdDurationOption{
{Days: 30, Label: "1 个月", PriceHint: "", PriceHintText: ""},
{Days: 60, Label: "2 个月", PriceHint: "", PriceHintText: ""},
{Days: 90, Label: "3 个月", PriceHint: "", PriceHintText: ""},
{Days: 180, Label: "6 个月", PriceHint: "", PriceHintText: ""},
{Days: 365, Label: "12 个月", PriceHint: "", PriceHintText: ""},
},
Payments: []AdPaymentOption{},
}
}
// AdService 广告业务
type AdService struct {
db *gorm.DB
captcha *CaptchaStore
}
func NewAdService(db *gorm.DB, captcha *CaptchaStore) *AdService {
return &AdService{db: db, captcha: captcha}
}
func (s *AdService) Captcha() *CaptchaStore { return s.captcha }
func (s *AdService) loadConfig() (AdConfig, error) {
var row model.SiteSetting
err := s.db.Where("key = ?", SettingKeyAdsConfig).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return DefaultAdConfig(), nil
}
if err != nil {
return AdConfig{}, err
}
cfg := DefaultAdConfig()
if strings.TrimSpace(row.Value) == "" {
return cfg, nil
}
if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
return DefaultAdConfig(), nil
}
if strings.TrimSpace(cfg.PanelTitle) == "" || cfg.PanelTitle == "赞助商" {
cfg.PanelTitle = AdDefaultTitle
}
if isLegacyAdDurations(cfg.Durations) {
cfg.Durations = DefaultAdConfig().Durations
}
for i := range cfg.Durations {
if strings.TrimSpace(cfg.Durations[i].PriceHint) == "面议" {
cfg.Durations[i].PriceHint = ""
}
if strings.TrimSpace(cfg.Durations[i].PriceHintText) == "面议" {
cfg.Durations[i].PriceHintText = ""
}
}
return cfg, nil
}
func (s *AdService) GetConfig() (AdConfig, error) {
return s.loadConfig()
}
// PublicConfig 购买页可见配置(仅启用的支付方式)
func (s *AdService) PublicConfig() (AdConfig, error) {
cfg, err := s.loadConfig()
if err != nil {
return cfg, err
}
pays := make([]AdPaymentOption, 0, len(cfg.Payments))
for _, p := range cfg.Payments {
if p.Enabled && strings.TrimSpace(p.QRURL) != "" {
pays = append(pays, p)
}
}
cfg.Payments = pays
return cfg, nil
}
func (s *AdService) SaveConfig(in AdConfig) (AdConfig, error) {
out, err := normalizeAdConfig(in)
if err != nil {
return AdConfig{}, err
}
b, err := json.Marshal(out)
if err != nil {
return AdConfig{}, err
}
row := model.SiteSetting{Key: SettingKeyAdsConfig, Value: string(b), UpdatedAt: time.Now()}
if err := s.db.Save(&row).Error; err != nil {
return AdConfig{}, err
}
return out, nil
}
func normalizeAdConfig(in AdConfig) (AdConfig, error) {
out := DefaultAdConfig()
out.Enabled = in.Enabled
title := strings.TrimSpace(in.PanelTitle)
if title == "" {
title = AdDefaultTitle
}
if utf8.RuneCountInString(title) > 16 {
return AdConfig{}, ErrAdInvalid
}
out.PanelTitle = title
if len(in.Durations) == 0 || len(in.Durations) > 12 {
return AdConfig{}, errors.New("请配置 1–12 个时长档位")
}
durs := make([]AdDurationOption, 0, len(in.Durations))
seenDays := map[int]struct{}{}
for _, d := range in.Durations {
if d.Days < 1 || d.Days > 366 {
return AdConfig{}, errors.New("时长须为 1–366 天")
}
if _, ok := seenDays[d.Days]; ok {
return AdConfig{}, errors.New("时长档位不可重复")
}
seenDays[d.Days] = struct{}{}
label := strings.TrimSpace(d.Label)
if label == "" {
label = formatDaysLabel(d.Days)
}
if utf8.RuneCountInString(label) > 20 {
return AdConfig{}, ErrAdInvalid
}
price := strings.TrimSpace(d.PriceHint)
if price == "面议" {
price = ""
}
if utf8.RuneCountInString(price) > 32 {
return AdConfig{}, ErrAdInvalid
}
priceText := strings.TrimSpace(d.PriceHintText)
if priceText == "面议" {
priceText = ""
}
if utf8.RuneCountInString(priceText) > 32 {
return AdConfig{}, ErrAdInvalid
}
durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price, PriceHintText: priceText})
}
out.Durations = durs
if len(in.Payments) > 8 {
return AdConfig{}, errors.New("支付方式最多 8 个")
}
pays := make([]AdPaymentOption, 0, len(in.Payments))
seenID := map[string]struct{}{}
for i, p := range in.Payments {
id := strings.TrimSpace(p.ID)
if id == "" {
id = "pay_" + strings.TrimSpace(strings.ToLower(p.Kind)) + "_" + itoa(i+1)
}
if !regexp.MustCompile(`^[a-zA-Z0-9_-]{2,32}$`).MatchString(id) {
return AdConfig{}, errors.New("支付方式 ID 非法")
}
if _, ok := seenID[id]; ok {
return AdConfig{}, errors.New("支付方式 ID 重复")
}
seenID[id] = struct{}{}
name := strings.TrimSpace(p.Name)
if name == "" || utf8.RuneCountInString(name) > 20 {
return AdConfig{}, errors.New("支付方式名称无效")
}
kind := strings.TrimSpace(strings.ToLower(p.Kind))
switch kind {
case "alipay", "wechat", "other":
default:
return AdConfig{}, errors.New("支付类型须为 alipay / wechat / other")
}
qr, ok := normalizeAdAssetURL(p.QRURL, true)
if p.Enabled && !ok {
return AdConfig{}, errors.New("启用中的支付方式须配置有效收款码图片")
}
if !p.Enabled && strings.TrimSpace(p.QRURL) != "" && !ok {
return AdConfig{}, errors.New("收款码图片地址无效")
}
if !ok {
qr = ""
}
hint := strings.TrimSpace(p.Hint)
if utf8.RuneCountInString(hint) > 80 {
return AdConfig{}, ErrAdInvalid
}
pays = append(pays, AdPaymentOption{
ID: id, Name: name, Kind: kind, QRURL: qr, Hint: hint, Enabled: p.Enabled,
})
}
out.Payments = pays
return out, nil
}
// isLegacyAdDurations 识别早期默认 7/30/90 天档位,读出时换成月档
func isLegacyAdDurations(durs []AdDurationOption) bool {
if len(durs) != 3 {
return false
}
return durs[0].Days == 7 && durs[1].Days == 30 && durs[2].Days == 90
}
func formatDaysLabel(days int) string {
switch days {
case 30:
return "1 个月"
case 60:
return "2 个月"
case 90:
return "3 个月"
case 180:
return "6 个月"
case 365:
return "12 个月"
default:
return itoa(days) + " 天"
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [12]byte
i := len(b)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
func normalizeAdAssetURL(raw string, allowEmpty bool) (string, bool) {
u := strings.TrimSpace(raw)
if u == "" {
return "", allowEmpty
}
if adImageUploadRe.MatchString(u) {
return u, true
}
if adImageHTTPSRe.MatchString(u) {
parsed, err := url.Parse(u)
if err != nil || parsed.Host == "" {
return "", false
}
return u, true
}
return "", false
}
func normalizeAdLinkURL(raw string) (string, bool) {
u := strings.TrimSpace(raw)
if u == "" || utf8.RuneCountInString(u) > 500 {
return "", false
}
lower := strings.ToLower(u)
if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "data:") {
return "", false
}
if !strings.HasPrefix(lower, "https://") {
return "", false
}
parsed, err := url.Parse(u)
if err != nil || parsed.Host == "" {
return "", false
}
return u, true
}
func normalizeHexColor(raw, fallback string) (string, bool) {
s := strings.TrimSpace(raw)
if s == "" {
return fallback, true
}
if !hexColorRe.MatchString(s) {
return "", false
}
return strings.ToUpper(s), true
}
// AdSubmitInput 游客提交
type AdSubmitInput struct {
Kind string
ContactEmail string
LinkURL string
ImageURL string
Title string
TextColor string
BgColor string
DurationDays int
PaymentID string
BuyerNote string
CaptchaID string
CaptchaCode string
IP string
}
func (s *AdService) Submit(in AdSubmitInput) (*model.Ad, error) {
cfg, err := s.loadConfig()
if err != nil {
return nil, err
}
if !cfg.Enabled {
return nil, ErrAdDisabled
}
if s.captcha == nil || !s.captcha.Verify(in.CaptchaID, in.CaptchaCode) {
return nil, ErrAdCaptcha
}
kind := strings.TrimSpace(in.Kind)
if kind != model.AdKindImage && kind != model.AdKindText {
return nil, ErrAdInvalid
}
email := strings.TrimSpace(in.ContactEmail)
if _, err := mail.ParseAddress(email); err != nil || len(email) > 128 {
return nil, errors.New("请填写有效的联系邮箱")
}
link, ok := normalizeAdLinkURL(in.LinkURL)
if !ok {
return nil, errors.New("广告链接须为 https 地址")
}
var daysOK bool
for _, d := range cfg.Durations {
if d.Days == in.DurationDays {
daysOK = true
break
}
}
if !daysOK {
return nil, ErrAdBadDuration
}
payOK := false
payID := strings.TrimSpace(in.PaymentID)
for _, p := range cfg.Payments {
if p.Enabled && p.ID == payID && strings.TrimSpace(p.QRURL) != "" {
payOK = true
break
}
}
if !payOK {
return nil, ErrAdBadPayment
}
note := strings.TrimSpace(in.BuyerNote)
if utf8.RuneCountInString(note) > AdMaxNoteRunes {
return nil, errors.New("备注过长")
}
ad := &model.Ad{
Kind: kind,
Status: model.AdStatusPending,
ContactEmail: email,
LinkURL: link,
DurationDays: in.DurationDays,
PaymentID: payID,
BuyerNote: note,
SubmitIP: strings.TrimSpace(in.IP),
}
switch kind {
case model.AdKindImage:
img, ok := normalizeAdAssetURL(in.ImageURL, false)
if !ok || !strings.HasPrefix(strings.ToLower(img), "https://") {
return nil, errors.New("图片地址须为 https(支持 png/jpg/gif/webp)")
}
ad.ImageURL = img
case model.AdKindText:
title := strings.TrimSpace(in.Title)
if title == "" || utf8.RuneCountInString(title) > AdMaxTitleRunes {
return nil, errors.New("请填写 1–24 字广告标题")
}
tc, ok := normalizeHexColor(in.TextColor, "#1E293B")
if !ok {
return nil, errors.New("文字颜色须为 #RRGGBB")
}
bc, ok := normalizeHexColor(in.BgColor, "#F1F5F9")
if !ok {
return nil, errors.New("背景颜色须为 #RRGGBB")
}
ad.Title = title
ad.TextColor = tc
ad.BgColor = bc
}
if err := s.db.Create(ad).Error; err != nil {
return nil, err
}
return ad, nil
}
// ActivePublic 侧栏展示用(自动视过期为失效;按类型各取上限)
func (s *AdService) ActivePublic() ([]model.Ad, AdConfig, error) {
cfg, err := s.loadConfig()
if err != nil {
return nil, cfg, err
}
if !cfg.Enabled {
return []model.Ad{}, cfg, nil
}
now := time.Now()
_ = s.db.Model(&model.Ad{}).
Where("status = ? AND ends_at IS NOT NULL AND ends_at < ?", model.AdStatusActive, now).
Update("status", model.AdStatusExpired).Error
base := s.db.Where("status = ?", model.AdStatusActive).
Where("(ends_at IS NULL OR ends_at >= ?)", now).
Order("sort_order ASC, id DESC")
var images, texts []model.Ad
if err := base.Session(&gorm.Session{}).Where("kind = ?", model.AdKindImage).
Limit(AdMaxImageShow).Find(&images).Error; err != nil {
return nil, cfg, err
}
if err := base.Session(&gorm.Session{}).Where("kind = ?", model.AdKindText).
Limit(AdMaxTextShow).Find(&texts).Error; err != nil {
return nil, cfg, err
}
list := make([]model.Ad, 0, len(images)+len(texts))
list = append(list, images...)
list = append(list, texts...)
return list, cfg, nil
}
func (s *AdService) AdminList(status string, page, size int) ([]model.Ad, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
q := s.db.Model(&model.Ad{})
status = strings.TrimSpace(status)
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.Ad
err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&list).Error
return list, total, err
}
func (s *AdService) Approve(actorID, id uint) (*model.Ad, error) {
var ad model.Ad
if err := s.db.First(&ad, id).Error; err != nil {
return nil, ErrAdNotFound
}
if ad.Status != model.AdStatusPending && ad.Status != model.AdStatusExpired && ad.Status != model.AdStatusRejected {
if ad.Status != model.AdStatusActive {
return nil, errors.New("当前状态不可上架")
}
}
now := time.Now()
ends := now.Add(time.Duration(ad.DurationDays) * 24 * time.Hour)
ad.Status = model.AdStatusActive
ad.StartsAt = &now
ad.EndsAt = &ends
ad.ReviewedAt = &now
ad.ReviewedBy = &actorID
ad.RejectReason = ""
if err := s.db.Save(&ad).Error; err != nil {
return nil, err
}
return &ad, nil
}
func (s *AdService) Reject(actorID, id uint, reason string) (*model.Ad, error) {
var ad model.Ad
if err := s.db.First(&ad, id).Error; err != nil {
return nil, ErrAdNotFound
}
if ad.Status != model.AdStatusPending && ad.Status != model.AdStatusActive {
return nil, errors.New("当前状态不可拒绝")
}
reason = strings.TrimSpace(reason)
if utf8.RuneCountInString(reason) > 100 {
return nil, errors.New("拒绝原因过长")
}
now := time.Now()
ad.Status = model.AdStatusRejected
ad.RejectReason = reason
ad.ReviewedAt = &now
ad.ReviewedBy = &actorID
if err := s.db.Save(&ad).Error; err != nil {
return nil, err
}
return &ad, nil
}
func (s *AdService) Delete(id uint) error {
res := s.db.Delete(&model.Ad{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrAdNotFound
}
return nil
}
func (s *AdService) SetSort(id uint, sort int) error {
res := s.db.Model(&model.Ad{}).Where("id = ?", id).Update("sort_order", sort)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrAdNotFound
}
return nil
}