- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
592 lines
15 KiB
Go
592 lines
15 KiB
Go
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
|
||
}
|