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 }