diff --git a/backend/handler/ad.go b/backend/handler/ad.go new file mode 100644 index 0000000..625cfef --- /dev/null +++ b/backend/handler/ad.go @@ -0,0 +1,253 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/service" + "github.com/gin-gonic/gin" +) + +// CaptchaIssue GET /api/captcha +func (h *Handlers) CaptchaIssue(c *gin.Context) { + if h.Ads == nil || h.Ads.Captcha() == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "验证码服务不可用"}) + return + } + id, img, err := h.Ads.Captcha().Issue() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "生成验证码失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"id": id, "image": img}) +} + +// AdsPublicConfig GET /api/ads/config +func (h *Handlers) AdsPublicConfig(c *gin.Context) { + cfg, err := h.Ads.PublicConfig() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取配置失败"}) + return + } + c.JSON(http.StatusOK, cfg) +} + +type adSubmitReq struct { + Kind string `json:"kind"` + ContactEmail string `json:"contact_email"` + LinkURL string `json:"link_url"` + ImageURL string `json:"image_url"` + Title string `json:"title"` + TextColor string `json:"text_color"` + BgColor string `json:"bg_color"` + DurationDays int `json:"duration_days"` + PaymentID string `json:"payment_id"` + BuyerNote string `json:"buyer_note"` + CaptchaID string `json:"captcha_id"` + CaptchaCode string `json:"captcha_code"` +} + +// AdsSubmit POST /api/ads +func (h *Handlers) AdsSubmit(c *gin.Context) { + var req adSubmitReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"}) + return + } + ad, err := h.Ads.Submit(service.AdSubmitInput{ + Kind: req.Kind, + ContactEmail: req.ContactEmail, + LinkURL: req.LinkURL, + ImageURL: req.ImageURL, + Title: req.Title, + TextColor: req.TextColor, + BgColor: req.BgColor, + DurationDays: req.DurationDays, + PaymentID: req.PaymentID, + BuyerNote: req.BuyerNote, + CaptchaID: req.CaptchaID, + CaptchaCode: req.CaptchaCode, + IP: c.ClientIP(), + }) + if err != nil { + status := http.StatusBadRequest + switch { + case errors.Is(err, service.ErrAdCaptcha): + status = http.StatusForbidden + case errors.Is(err, service.ErrAdDisabled): + status = http.StatusServiceUnavailable + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ad": ad, "message": "提交成功,请完成付款并等待审核"}) +} + +// AdminGetAdsConfig GET /api/admin/ads/config +func (h *Handlers) AdminGetAdsConfig(c *gin.Context) { + cfg, err := h.Ads.GetConfig() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"}) + return + } + c.JSON(http.StatusOK, cfg) +} + +// AdminSaveAdsConfig PUT /api/admin/ads/config +func (h *Handlers) AdminSaveAdsConfig(c *gin.Context) { + var req service.AdConfig + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"}) + return + } + cfg, err := h.Ads.SaveConfig(req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, cfg) +} + +// AdminListAds GET /api/admin/ads +func (h *Handlers) AdminListAds(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + list, total, err := h.Ads.AdminList(c.Query("status"), page, size) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"ads": list, "total": total, "page": page, "size": size}) +} + +// AdminApproveAd PUT /api/admin/ads/:id/approve +func (h *Handlers) AdminApproveAd(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"}) + return + } + actor := middleware.CurrentActor(c) + ad, err := h.Ads.Approve(actor.ID, uint(id)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ad": ad}) +} + +type adRejectReq struct { + Reason string `json:"reason"` +} + +// AdminRejectAd PUT /api/admin/ads/:id/reject +func (h *Handlers) AdminRejectAd(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"}) + return + } + var req adRejectReq + _ = c.ShouldBindJSON(&req) + actor := middleware.CurrentActor(c) + ad, err := h.Ads.Reject(actor.ID, uint(id), req.Reason) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ad": ad}) +} + +// AdminDeleteAd DELETE /api/admin/ads/:id +func (h *Handlers) AdminDeleteAd(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"}) + return + } + if err := h.Ads.Delete(uint(id)); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +type adSortReq struct { + SortOrder int `json:"sort_order"` +} + +// AdminSortAd PUT /api/admin/ads/:id/sort +func (h *Handlers) AdminSortAd(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"}) + return + } + var req adSortReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"}) + return + } + if err := h.Ads.SetSort(uint(id), req.SortOrder); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// AdminUploadAdQR POST /api/admin/upload/ad-qr — 收款码上传到 /uploads/ads/ +func (h *Handlers) AdminUploadAdQR(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.AdAssetMaxBytes+4096) + fh, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"}) + return + } + f, err := fh.Open() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"}) + return + } + defer f.Close() + url, err := h.Upload.SaveAdAsset(f) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"url": url}) +} + +// AdminGetSponsorsConfig GET /api/admin/sponsors/config +func (h *Handlers) AdminGetSponsorsConfig(c *gin.Context) { + if h.Ads == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务不可用"}) + return + } + cfg, err := h.Ads.GetSponsorsConfig() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, cfg) +} + +// AdminSaveSponsorsConfig PUT /api/admin/sponsors/config +func (h *Handlers) AdminSaveSponsorsConfig(c *gin.Context) { + if h.Ads == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务不可用"}) + return + } + var req service.SponsorsConfig + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"}) + return + } + cfg, err := h.Ads.SaveSponsorsConfig(req) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, cfg) +} diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 5cb252d..34ddd05 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -33,6 +33,7 @@ type Handlers struct { Telemetry *service.TelemetryService Analytics *service.AnalyticsService HidePwd *service.HidePasswordCookie + Ads *service.AdService } // resolvePublishStatus 决定新帖/新评的初始状态: diff --git a/backend/handler/overview.go b/backend/handler/overview.go index 39b5f8c..b4b8c56 100644 --- a/backend/handler/overview.go +++ b/backend/handler/overview.go @@ -38,5 +38,11 @@ func (h *Handlers) Overview(c *gin.Context) { "sidebar_pages": data.SidebarPages, "new_users": data.NewUsers, "checkin": data.Checkin, + "ads": data.Ads, + "ads_panel_title": data.AdsPanelTitle, + "ads_enabled": data.AdsEnabled, + "sponsors": data.Sponsors, + "sponsors_panel_title": data.SponsorsPanelTitle, + "sponsors_enabled": data.SponsorsEnabled, }) } diff --git a/backend/model/ad.go b/backend/model/ad.go new file mode 100644 index 0000000..745e11e --- /dev/null +++ b/backend/model/ad.go @@ -0,0 +1,44 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// 广告类型与状态 +const ( + AdKindImage = "image" + AdKindText = "text" + + AdStatusPending = "pending" + AdStatusActive = "active" + AdStatusRejected = "rejected" + AdStatusExpired = "expired" +) + +// Ad 赞助广告(游客可申购,管理员审核后展示于首页侧栏) +type Ad struct { + ID uint `gorm:"primaryKey" json:"id"` + Kind string `gorm:"size:16;not null;index" json:"kind"` // image | text + Status string `gorm:"size:16;not null;index" json:"status"` // pending/active/rejected/expired + ContactEmail string `gorm:"size:128;not null" json:"contact_email"` + LinkURL string `gorm:"size:512;not null" json:"link_url"` + ImageURL string `gorm:"size:512" json:"image_url"` // 图片广告 + Title string `gorm:"size:64" json:"title"` // 文字广告 + TextColor string `gorm:"size:16" json:"text_color"` + BgColor string `gorm:"size:16" json:"bg_color"` + DurationDays int `gorm:"not null" json:"duration_days"` + PaymentID string `gorm:"size:64;not null" json:"payment_id"` + BuyerNote string `gorm:"size:200" json:"buyer_note"` + StartsAt *time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + RejectReason string `gorm:"size:200" json:"reject_reason"` + SortOrder int `gorm:"not null;default:0" json:"sort_order"` + SubmitIP string `gorm:"size:45" json:"-"` + ReviewedAt *time.Time `json:"reviewed_at"` + ReviewedBy *uint `json:"reviewed_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} diff --git a/backend/model/db.go b/backend/model/db.go index d1df38d..f409255 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -61,7 +61,7 @@ func InitDB(dsn string) error { &Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{}, &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{}, - &PostPollVote{}, &PostLotteryEntry{}, + &PostPollVote{}, &PostLotteryEntry{}, &Ad{}, ); err != nil { return fmt.Errorf("自动迁移失败: %w", err) } @@ -78,6 +78,11 @@ func InitDB(dsn string) error { return fmt.Errorf("login_logs 默认值修正失败: %w", err) } + // 早期 site_settings.value 可能是 varchar(255);广告/赞助商 JSON 会超长 + if err := prepareSiteSettingValueText(db); err != nil { + return fmt.Errorf("site_settings.value 扩容失败: %w", err) + } + // 新表结构就位后删除遗留的明文列 if err := dropLegacyRefreshTokenColumn(db); err != nil { return fmt.Errorf("refresh token 旧列清理失败: %w", err) @@ -152,6 +157,28 @@ func prepareAnnouncementPinnedColumn(db *gorm.DB) error { return db.Exec(`ALTER TABLE announcements ADD COLUMN pinned boolean NOT NULL DEFAULT false`).Error } +// prepareSiteSettingValueText 将 site_settings.value 从 varchar(255) 扩为 text(幂等) +func prepareSiteSettingValueText(db *gorm.DB) error { + var tableCount int64 + if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'site_settings'`). + Scan(&tableCount).Error; err != nil { + return err + } + if tableCount == 0 { + return nil + } + var dataType string + if err := db.Raw(`SELECT data_type FROM information_schema.columns + WHERE table_name = 'site_settings' AND column_name = 'value'`). + Scan(&dataType).Error; err != nil { + return err + } + if dataType == "" || dataType == "text" { + return nil + } + return db.Exec(`ALTER TABLE site_settings ALTER COLUMN value TYPE text`).Error +} + // prepareRefreshTokenSessionColumns 存量 refresh_tokens 加 logged_in_at / last_used_at: // 先可空加列,用 created_at 回填后再收紧 NOT NULL。 func prepareRefreshTokenSessionColumns(db *gorm.DB) error { diff --git a/backend/router/router.go b/backend/router/router.go index 9fac9ff..5148ab4 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -74,6 +74,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { chatSvc := service.NewChatService(model.DB, notifSvc) telemetrySvc := service.NewTelemetryService(model.DB) analyticsSvc := service.NewAnalyticsService(model.DB) + adSvc := service.NewAdService(model.DB, service.NewCaptchaStore()) + overviewSvc.WithAds(adSvc) if err := uploadSvc.EnsureDir(); err != nil { return nil, err } @@ -106,6 +108,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Telemetry: telemetrySvc, Analytics: analyticsSvc, HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode), + Ads: adSvc, } // 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用) notifSvc.OnNotifyNew = func(userID uint) { @@ -161,6 +164,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.POST("/auth/refresh", middleware.CSRFMiddleware(), h.Refresh) // 登出不依赖有效登录态:封禁/登录态失效后前端仍需凭它清除 cookie(CSRF 仍校验) pubAPI.POST("/logout", middleware.CSRFMiddleware(), h.Logout) + pubAPI.GET("/captcha", middleware.RateLimitMiddleware(limiter, service.RateCaptcha), h.CaptchaIssue) + pubAPI.GET("/ads/config", h.AdsPublicConfig) + pubAPI.POST("/ads", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateAdSubmit), h.AdsSubmit) } // 需登录 API(先鉴权,再 CSRF 防护) @@ -284,6 +290,19 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage) announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage) + // 广告位管理(管理员及以上) + adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements)) + adsAPI.GET("/ads/config", h.AdminGetAdsConfig) + adsAPI.PUT("/ads/config", h.AdminSaveAdsConfig) + adsAPI.GET("/ads", h.AdminListAds) + adsAPI.PUT("/ads/:id/approve", h.AdminApproveAd) + adsAPI.PUT("/ads/:id/reject", h.AdminRejectAd) + adsAPI.PUT("/ads/:id/sort", h.AdminSortAd) + adsAPI.DELETE("/ads/:id", h.AdminDeleteAd) + adsAPI.POST("/upload/ad-qr", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminUploadAdQR) + adsAPI.GET("/sponsors/config", h.AdminGetSponsorsConfig) + adsAPI.PUT("/sponsors/config", h.AdminSaveSponsorsConfig) + // 站点设置(超级管理员/站长) staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings) opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings)) diff --git a/backend/service/ad.go b/backend/service/ad.go new file mode 100644 index 0000000..3394509 --- /dev/null +++ b/backend/service/ad.go @@ -0,0 +1,580 @@ +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"` +} + +// 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: ""}, + {Days: 60, Label: "2 个月", PriceHint: ""}, + {Days: 90, Label: "3 个月", PriceHint: ""}, + {Days: 180, Label: "6 个月", PriceHint: ""}, + {Days: 365, Label: "12 个月", PriceHint: ""}, + }, + 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 = "" + } + } + 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 + } + durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price}) + } + 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 +} diff --git a/backend/service/captcha.go b/backend/service/captcha.go new file mode 100644 index 0000000..32a151a --- /dev/null +++ b/backend/service/captcha.go @@ -0,0 +1,236 @@ +package service + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "image" + "image/color" + "image/draw" + "image/png" + "math/big" + "strings" + "sync" + "time" +) + +const ( + captchaTTL = 5 * time.Minute + captchaLen = 5 + captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // 去掉易混 I/O/0/1 +) + +type captchaEntry struct { + answer string + expiresAt time.Time +} + +// CaptchaStore 内存图形验证码(单实例足够;重启后未用完的码失效) +type CaptchaStore struct { + mu sync.Mutex + data map[string]captchaEntry +} + +func NewCaptchaStore() *CaptchaStore { + s := &CaptchaStore{data: make(map[string]captchaEntry)} + go s.loopPurge() + return s +} + +func (s *CaptchaStore) loopPurge() { + t := time.NewTicker(2 * time.Minute) + defer t.Stop() + for range t.C { + s.mu.Lock() + now := time.Now() + for id, e := range s.data { + if now.After(e.expiresAt) { + delete(s.data, id) + } + } + s.mu.Unlock() + } +} + +// Issue 生成验证码,返回 id 与 PNG data URL +func (s *CaptchaStore) Issue() (id, dataURL string, err error) { + answer, err := randomCaptchaText(captchaLen) + if err != nil { + return "", "", err + } + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", "", err + } + id = hex.EncodeToString(b) + img, err := renderCaptchaPNG(answer) + if err != nil { + return "", "", err + } + s.mu.Lock() + s.data[id] = captchaEntry{answer: strings.ToUpper(answer), expiresAt: time.Now().Add(captchaTTL)} + s.mu.Unlock() + dataURL = "data:image/png;base64," + base64.StdEncoding.EncodeToString(img) + return id, dataURL, nil +} + +// Verify 校验并立即作废(一次性) +func (s *CaptchaStore) Verify(id, answer string) bool { + id = strings.TrimSpace(id) + answer = strings.ToUpper(strings.TrimSpace(answer)) + if id == "" || answer == "" { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.data[id] + if !ok { + return false + } + delete(s.data, id) + if time.Now().After(e.expiresAt) { + return false + } + return e.answer == answer +} + +func randomCaptchaText(n int) (string, error) { + var b strings.Builder + max := big.NewInt(int64(len(captchaCharset))) + for i := 0; i < n; i++ { + v, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + b.WriteByte(captchaCharset[v.Int64()]) + } + return b.String(), nil +} + +func renderCaptchaPNG(text string) ([]byte, error) { + const w, h = 140, 44 + img := image.NewRGBA(image.Rect(0, 0, w, h)) + bg := color.RGBA{R: 248, G: 250, B: 252, A: 255} + draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src) + + // 噪点 + for i := 0; i < 180; i++ { + x, _ := rand.Int(rand.Reader, big.NewInt(w)) + y, _ := rand.Int(rand.Reader, big.NewInt(h)) + img.Set(int(x.Int64()), int(y.Int64()), color.RGBA{R: 180, G: 190, B: 200, A: 255}) + } + // 干扰线 + for i := 0; i < 4; i++ { + x0, _ := rand.Int(rand.Reader, big.NewInt(w)) + y0, _ := rand.Int(rand.Reader, big.NewInt(h)) + x1, _ := rand.Int(rand.Reader, big.NewInt(w)) + y1, _ := rand.Int(rand.Reader, big.NewInt(h)) + drawLine(img, int(x0.Int64()), int(y0.Int64()), int(x1.Int64()), int(y1.Int64()), color.RGBA{R: 160, G: 170, B: 185, A: 255}) + } + + fg := color.RGBA{R: 30, G: 41, B: 59, A: 255} + startX := 12 + for i, ch := range text { + ox := startX + i*24 + oy := 10 + int(i%2)*2 + drawGlyph(img, ch, ox, oy, fg) + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func drawLine(img *image.RGBA, x0, y0, x1, y1 int, c color.Color) { + dx := abs(x1 - x0) + dy := -abs(y1 - y0) + sx, sy := 1, 1 + if x0 >= x1 { + sx = -1 + } + if y0 >= y1 { + sy = -1 + } + err := dx + dy + for { + img.Set(x0, y0, c) + if x0 == x1 && y0 == y1 { + break + } + e2 := 2 * err + if e2 >= dy { + err += dy + x0 += sx + } + if e2 <= dx { + err += dx + y0 += sy + } + } +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +// 简易 5×7 点阵(仅验证码字符集) +var glyphBits = map[rune][7]string{ + 'A': {"01110", "10001", "10001", "11111", "10001", "10001", "10001"}, + 'B': {"11110", "10001", "10001", "11110", "10001", "10001", "11110"}, + 'C': {"01111", "10000", "10000", "10000", "10000", "10000", "01111"}, + 'D': {"11110", "10001", "10001", "10001", "10001", "10001", "11110"}, + 'E': {"11111", "10000", "10000", "11110", "10000", "10000", "11111"}, + 'F': {"11111", "10000", "10000", "11110", "10000", "10000", "10000"}, + 'G': {"01111", "10000", "10000", "10011", "10001", "10001", "01111"}, + 'H': {"10001", "10001", "10001", "11111", "10001", "10001", "10001"}, + 'J': {"00111", "00010", "00010", "00010", "00010", "10010", "01100"}, + 'K': {"10001", "10010", "10100", "11000", "10100", "10010", "10001"}, + 'L': {"10000", "10000", "10000", "10000", "10000", "10000", "11111"}, + 'M': {"10001", "11011", "10101", "10001", "10001", "10001", "10001"}, + 'N': {"10001", "11001", "10101", "10011", "10001", "10001", "10001"}, + 'P': {"11110", "10001", "10001", "11110", "10000", "10000", "10000"}, + 'Q': {"01110", "10001", "10001", "10001", "10101", "10010", "01101"}, + 'R': {"11110", "10001", "10001", "11110", "10100", "10010", "10001"}, + 'S': {"01111", "10000", "10000", "01110", "00001", "00001", "11110"}, + 'T': {"11111", "00100", "00100", "00100", "00100", "00100", "00100"}, + 'U': {"10001", "10001", "10001", "10001", "10001", "10001", "01110"}, + 'V': {"10001", "10001", "10001", "10001", "10001", "01010", "00100"}, + 'W': {"10001", "10001", "10001", "10001", "10101", "10101", "01010"}, + 'X': {"10001", "10001", "01010", "00100", "01010", "10001", "10001"}, + 'Y': {"10001", "10001", "01010", "00100", "00100", "00100", "00100"}, + 'Z': {"11111", "00001", "00010", "00100", "01000", "10000", "11111"}, + '2': {"01110", "10001", "00001", "00010", "00100", "01000", "11111"}, + '3': {"11110", "00001", "00001", "01110", "00001", "00001", "11110"}, + '4': {"00010", "00110", "01010", "10010", "11111", "00010", "00010"}, + '5': {"11111", "10000", "11110", "00001", "00001", "10001", "01110"}, + '6': {"01110", "10000", "10000", "11110", "10001", "10001", "01110"}, + '7': {"11111", "00001", "00010", "00100", "01000", "01000", "01000"}, + '8': {"01110", "10001", "10001", "01110", "10001", "10001", "01110"}, + '9': {"01110", "10001", "10001", "01111", "00001", "00001", "01110"}, +} + +func drawGlyph(img *image.RGBA, ch rune, ox, oy int, c color.Color) { + bits, ok := glyphBits[ch] + if !ok { + return + } + scale := 2 + for row, line := range bits { + for col, cell := range line { + if cell != '1' { + continue + } + for dy := 0; dy < scale; dy++ { + for dx := 0; dx < scale; dx++ { + img.Set(ox+col*scale+dx, oy+row*scale+dy, c) + } + } + } + } +} diff --git a/backend/service/overview.go b/backend/service/overview.go index 8efe559..0735831 100644 --- a/backend/service/overview.go +++ b/backend/service/overview.go @@ -10,13 +10,19 @@ import ( // OverviewService 首页聚合服务:一次请求返回统计、热门榜、活跃用户、板块计数 type OverviewService struct { - db *gorm.DB + db *gorm.DB + ads *AdService } func NewOverviewService(db *gorm.DB) *OverviewService { return &OverviewService{db: db} } +func (s *OverviewService) WithAds(ads *AdService) *OverviewService { + s.ads = ads + return s +} + // OverviewStats 社区整体统计(社区脉搏卡) type OverviewStats struct { Posts int64 `json:"posts"` @@ -75,6 +81,32 @@ type OverviewData struct { SidebarPages []SidebarPageItem `json:"sidebar_pages"` NewUsers []NewUserItem `json:"new_users"` Checkin *CheckinStatus `json:"checkin,omitempty"` + Ads []PublicAdItem `json:"ads"` + AdsPanelTitle string `json:"ads_panel_title"` + AdsEnabled bool `json:"ads_enabled"` + Sponsors []PublicSponsorItem `json:"sponsors"` + SponsorsPanelTitle string `json:"sponsors_panel_title"` + SponsorsEnabled bool `json:"sponsors_enabled"` +} + +// PublicAdItem 侧栏广告条目 +type PublicAdItem struct { + ID uint `json:"id"` + Kind string `json:"kind"` + LinkURL string `json:"link_url"` + ImageURL string `json:"image_url,omitempty"` + Title string `json:"title,omitempty"` + TextColor string `json:"text_color,omitempty"` + BgColor string `json:"bg_color,omitempty"` +} + +// PublicSponsorItem 侧栏赞助商 +type PublicSponsorItem struct { + ID string `json:"id"` + Name string `json:"name"` + LogoURL string `json:"logo_url"` + LinkURL string `json:"link_url,omitempty"` + HoverText string `json:"hover_text,omitempty"` } const ( @@ -286,6 +318,36 @@ LIMIT 5` }) } + data.Ads = []PublicAdItem{} + data.AdsPanelTitle = AdDefaultTitle + data.Sponsors = []PublicSponsorItem{} + data.SponsorsPanelTitle = SponsorDefaultTitle + if s.ads != nil { + list, cfg, err := s.ads.ActivePublic() + if err == nil { + data.AdsEnabled = cfg.Enabled + data.AdsPanelTitle = cfg.PanelTitle + for _, a := range list { + data.Ads = append(data.Ads, PublicAdItem{ + ID: a.ID, Kind: a.Kind, LinkURL: a.LinkURL, + ImageURL: a.ImageURL, Title: a.Title, + TextColor: a.TextColor, BgColor: a.BgColor, + }) + } + } + scfg, slist, err := s.ads.PublicSponsors() + if err == nil { + data.SponsorsEnabled = scfg.Enabled + data.SponsorsPanelTitle = scfg.PanelTitle + for _, it := range slist { + data.Sponsors = append(data.Sponsors, PublicSponsorItem{ + ID: it.ID, Name: it.Name, LogoURL: it.LogoURL, + LinkURL: it.LinkURL, HoverText: it.HoverText, + }) + } + } + } + return data, nil } diff --git a/backend/service/ratelimit.go b/backend/service/ratelimit.go index ba4219f..9632904 100644 --- a/backend/service/ratelimit.go +++ b/backend/service/ratelimit.go @@ -82,6 +82,8 @@ const ( RateInteract = "interact" // 投票/抽奖/解锁等互动 RateHidePassword = "hide_password" // 密码隐藏块尝试 RateTimelineGit = "timeline_git" // Git 提交导入 10/分钟 + RateAdSubmit = "ad_submit" // 广告申购 5/分钟(按 IP) + RateCaptcha = "captcha" // 验证码刷新 30/分钟 ) // DefaultRateLimiter 创建默认速率限制器 @@ -96,5 +98,7 @@ func DefaultRateLimiter() *RateLimiter { rl.SetLimit(RateInteract, 40) // 互动 40/分钟 rl.SetLimit(RateHidePassword, 20) // 密码尝试 20/分钟(按 IP) rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟 + rl.SetLimit(RateAdSubmit, 5) // 广告申购 5/分钟 + rl.SetLimit(RateCaptcha, 30) // 验证码 30/分钟 return rl } diff --git a/backend/service/sponsor.go b/backend/service/sponsor.go new file mode 100644 index 0000000..14299cf --- /dev/null +++ b/backend/service/sponsor.go @@ -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 +} diff --git a/backend/service/upload.go b/backend/service/upload.go index 9261f72..85bc6f2 100644 --- a/backend/service/upload.go +++ b/backend/service/upload.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "errors" "image" + _ "image/gif" _ "image/jpeg" _ "image/png" "io" @@ -77,6 +78,9 @@ func (s *UploadService) EnsureDir() error { if err := os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755); err != nil { return err } + if err := os.MkdirAll(filepath.Join(s.dir, "ads"), 0o755); err != nil { + return err + } return os.MkdirAll(filepath.Join(s.dir, "brand"), 0o755) } @@ -166,7 +170,10 @@ func detectImageFormat(data []byte) (imageFormat, error) { if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" { return imageFormat{ext: ".webp", mime: "image/webp"}, nil } - return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP") + if len(data) >= 6 && (string(data[0:6]) == "GIF87a" || string(data[0:6]) == "GIF89a") { + return imageFormat{ext: ".gif", mime: "image/gif"}, nil + } + return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP / GIF") } func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) { @@ -403,6 +410,91 @@ func (s *UploadService) SaveBackground(src io.Reader) (string, error) { return "/uploads/backgrounds/" + filename, nil } +const AdAssetMaxBytes = 2 << 20 // 收款码 / 广告素材 2MB + +// SaveAdAsset 保存广告相关图片(收款码等)到 uploads/ads,保留原格式含 GIF +func (s *UploadService) SaveAdAsset(src io.Reader) (string, error) { + if src == nil { + return "", errors.New("文件为空") + } + tooLarge := func() error { return errors.New("图片不能超过 2MB") } + + head := make([]byte, 12) + n, err := io.ReadFull(src, head) + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) { + return "", errors.New("读取图片失败") + } + if n == 0 { + return "", errors.New("文件为空") + } + format, err := detectImageFormat(head[:n]) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Join(s.dir, "ads"), 0o755); err != nil { + return "", err + } + nameBytes := make([]byte, 16) + if _, err := rand.Read(nameBytes); err != nil { + return "", err + } + filename := hex.EncodeToString(nameBytes) + format.ext + fullPath := filepath.Join(s.dir, "ads", filename) + tmp := fullPath + ".partial" + if s.ops != nil { + release, e := s.ops.BeginTemporary(tmp) + if e != nil { + return "", e + } + defer release() + } + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return "", err + } + remain := AdAssetMaxBytes + 1 - int64(n) + if remain < 0 { + _ = f.Close() + _ = os.Remove(tmp) + return "", tooLarge() + } + if _, err := f.Write(head[:n]); err != nil { + _ = f.Close() + _ = os.Remove(tmp) + return "", err + } + writtenRest, copyErr := io.Copy(f, io.LimitReader(src, remain)) + _ = f.Close() + if copyErr != nil { + _ = os.Remove(tmp) + return "", copyErr + } + if int64(n)+writtenRest > AdAssetMaxBytes { + _ = os.Remove(tmp) + return "", tooLarge() + } + rf, err := os.Open(tmp) + if err != nil { + _ = os.Remove(tmp) + return "", err + } + w, h, err := decodeImageSizeReader(rf, format.mime) + _ = rf.Close() + if err != nil { + _ = os.Remove(tmp) + return "", err + } + if w < 1 || h < 1 || w > 4096 || h > 4096 { + _ = os.Remove(tmp) + return "", errors.New("图片尺寸无效") + } + if err := os.Rename(tmp, fullPath); err != nil { + _ = os.Remove(tmp) + return "", err + } + return "/uploads/ads/" + filename, nil +} + // CopyBackgroundFromMedia 把当前用户媒体库里的一张图复制进 backgrounds,不写站点设置 func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (string, error) { var att model.Attachment diff --git a/frontend/app/admin/ads/AdsAdmin.tsx b/frontend/app/admin/ads/AdsAdmin.tsx new file mode 100644 index 0000000..62563d0 --- /dev/null +++ b/frontend/app/admin/ads/AdsAdmin.tsx @@ -0,0 +1,1103 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { + CircleDollarSign, + Clock, + Eye, + Handshake, + ImageIcon, + Inbox, + Megaphone, + Plus, + Trash2, + Type, + Upload, + Wallet, + XCircle, +} from "lucide-react"; +import { + apiAdminApproveAd, + apiAdminDeleteAd, + apiAdminListAds, + apiAdminRejectAd, + apiAdminSaveAdsConfig, + apiAdminSaveSponsorsConfig, + apiAdminUploadAdQR, + type AdConfig, + type AdPaymentOption, + type AdRecord, + type SponsorItem, + type SponsorsConfig, +} from "@/lib/api"; +import { + AdminEmpty, + AdminPageHeader, + AdminSegmented, + AdminSettingsPage, + AdminSettingsWork, + AdminStatCard, + AdminStatusChip, + AdminSwitch, +} from "@/components/admin"; +import ConfirmDialog from "@/components/ConfirmDialog"; +import Modal from "@/components/Modal"; +import { AdText } from "@/components/AdText"; +import { formatRelative } from "@/lib/format"; +import { toast } from "@/lib/toast"; + +type StatusFilter = "" | "pending" | "active" | "rejected" | "expired"; +type SectionId = "overview" | "queue" | "promote" | "sponsors"; + +const statusLabel: Record = { + pending: "待审", + active: "展示中", + rejected: "已拒绝", + expired: "已到期", +}; + +const SECTIONS: { id: SectionId; label: string; icon: typeof Inbox }[] = [ + { id: "overview", label: "概览", icon: Eye }, + { id: "queue", label: "推广申购", icon: Inbox }, + { id: "promote", label: "自助推广", icon: Megaphone }, + { id: "sponsors", label: "赞助商", icon: Handshake }, +]; + +const emptySponsors = (): SponsorsConfig => ({ + enabled: true, + panel_title: "赞助商", + items: [], +}); + +export default function AdsAdmin({ + initialAds, + initialConfig, + initialSponsors, + initError, +}: { + initialAds: AdRecord[]; + initialConfig: AdConfig | null; + initialSponsors: SponsorsConfig | null; + initError: string; +}) { + const [status, setStatus] = useState(""); + const [ads, setAds] = useState(initialAds); + const [cfg, setCfg] = useState( + initialConfig || { + enabled: true, + panel_title: "自助推广", + durations: [ + { days: 30, label: "1 个月", price_hint: "" }, + { days: 60, label: "2 个月", price_hint: "" }, + { days: 90, label: "3 个月", price_hint: "" }, + { days: 180, label: "6 个月", price_hint: "" }, + { days: 365, label: "12 个月", price_hint: "" }, + ], + payments: [], + } + ); + const [sponsors, setSponsors] = useState(initialSponsors || emptySponsors()); + const [busy, setBusy] = useState(false); + const [deleteId, setDeleteId] = useState(null); + const [rejectId, setRejectId] = useState(null); + const [rejectReason, setRejectReason] = useState(""); + const [activeSection, setActiveSection] = useState("overview"); + + const counts = useMemo(() => { + const c = { pending: 0, active: 0, rejected: 0, expired: 0 }; + for (const a of ads) { + if (a.status in c) (c as Record)[a.status] += 1; + } + return c; + }, [ads]); + + const visibleAds = useMemo(() => { + if (!status) return ads; + return ads.filter((a) => a.status === status); + }, [ads, status]); + + const reload = async (st: StatusFilter = status) => { + try { + const res = await apiAdminListAds(st); + setAds(res.ads ?? []); + } catch (e) { + toast(e instanceof Error ? e.message : "刷新失败", "error"); + } + }; + + const onApprove = async (id: number) => { + setBusy(true); + try { + await apiAdminApproveAd(id); + toast("已上架", "ok"); + await reload(); + } catch (e) { + toast(e instanceof Error ? e.message : "操作失败", "error"); + } finally { + setBusy(false); + } + }; + + const onRejectConfirm = async () => { + if (rejectId == null) return; + setBusy(true); + try { + await apiAdminRejectAd(rejectId, rejectReason.trim()); + toast("已拒绝", "ok"); + setRejectId(null); + setRejectReason(""); + await reload(); + } catch (e) { + toast(e instanceof Error ? e.message : "操作失败", "error"); + } finally { + setBusy(false); + } + }; + + const onDelete = async () => { + if (deleteId == null) return; + setBusy(true); + try { + await apiAdminDeleteAd(deleteId); + toast("已删除", "ok"); + setDeleteId(null); + await reload(); + } catch (e) { + toast(e instanceof Error ? e.message : "删除失败", "error"); + } finally { + setBusy(false); + } + }; + + const saveConfig = async () => { + setBusy(true); + try { + const next = await apiAdminSaveAdsConfig(cfg); + setCfg(next); + toast("配置已保存", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败", "error"); + } finally { + setBusy(false); + } + }; + + const saveSponsors = async () => { + setBusy(true); + try { + const next = await apiAdminSaveSponsorsConfig(sponsors); + setSponsors(next); + toast("赞助商已保存", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败", "error"); + } finally { + setBusy(false); + } + }; + + const uploadQR = async (index: number, file: File) => { + try { + const { url } = await apiAdminUploadAdQR(file); + setCfg((c) => { + const payments = [...c.payments]; + payments[index] = { ...payments[index], qr_url: url }; + return { ...c, payments }; + }); + toast("收款码已上传", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "上传失败", "error"); + } + }; + + const uploadSponsorLogo = async (index: number, file: File) => { + try { + const { url } = await apiAdminUploadAdQR(file); + setSponsors((c) => { + const items = [...c.items]; + items[index] = { ...items[index], logo_url: url }; + return { ...c, items }; + }); + toast("Logo 已上传", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "上传失败", "error"); + } + }; + + const addPayment = () => { + const id = `pay_${Date.now().toString(36)}`; + setCfg((c) => ({ + ...c, + payments: [ + ...c.payments, + { id, name: "支付宝", kind: "alipay", qr_url: "", hint: "付款请备注邮箱", enabled: true }, + ], + })); + }; + + const addSponsor = () => { + const id = `sp_${Date.now().toString(36)}`; + setSponsors((c) => ({ + ...c, + items: [ + ...c.items, + { + id, + name: "", + logo_url: "", + link_url: "", + hover_text: "", + enabled: true, + sort_order: c.items.length, + }, + ], + })); + }; + + // 粘性分区导航:滚动高亮当前区块 + const sectionRefs = useRef>({ + overview: null, + queue: null, + promote: null, + sponsors: null, + }); + + useEffect(() => { + const els = SECTIONS.map((s) => sectionRefs.current[s.id]).filter( + (el): el is HTMLElement => !!el + ); + if (els.length === 0) return; + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((e) => e.isIntersecting) + .sort((a, b) => b.intersectionRatio - a.intersectionRatio); + if (visible[0]) { + const id = (visible[0].target as HTMLElement).dataset.section as SectionId; + if (id) setActiveSection(id); + } + }, + { rootMargin: "-30% 0px -55% 0px", threshold: [0, 0.25, 0.5, 1] } + ); + els.forEach((el) => observer.observe(el)); + return () => observer.disconnect(); + }, []); + + const scrollTo = (id: SectionId) => { + const el = sectionRefs.current[id]; + if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + + if (initError && !initialConfig) { + return

{initError}

; + } + + const leftNav = ( + + ); + + return ( + + + 预览推广页 + + } + /> + + +
+ {/* ── 概览 KPI ─────────────────────────────── */} +
{ + sectionRefs.current.overview = el; + }} + > +
+ } + color="var(--gold)" + soft="var(--gold-soft)" + hint="需尽快处理" + /> + } + color="var(--ok)" + soft="color-mix(in srgb, var(--ok) 14%, var(--panel))" + hint="当前在线广告" + /> + } + color="var(--ink-3)" + soft="var(--panel-2)" + hint="可重新上架" + /> + } + color="var(--accent)" + soft="var(--accent-soft)" + hint={cfg.enabled ? "自助推广已开放" : "自助推广已关闭"} + /> + } + color="var(--accent)" + soft="var(--accent-soft)" + hint={sponsors.enabled ? "板块已展示" : "板块已隐藏"} + /> +
+
+ + {/* ── 推广申购 ─────────────────────────────── */} +
{ + sectionRefs.current.queue = el; + }} + className="panel overflow-hidden" + > + } + title="推广申购" + desc="游客提交的推广申请,审核通过后展示在首页侧栏" + extra={ + + ariaLabel="广告状态筛选" + size="sm" + role="group" + value={status} + onChange={(v) => { + setStatus(v); + void reload(v); + }} + options={[ + { key: "", label: "全部" }, + { key: "pending", label: "待审", count: counts.pending }, + { key: "active", label: "展示中", count: counts.active }, + { key: "rejected", label: "已拒绝" }, + { key: "expired", label: "已到期" }, + ]} + /> + } + /> +
+ {visibleAds.length === 0 ? ( + } + title="暂无广告记录" + description={status ? "该状态下没有记录" : "游客提交后会出现在此列表"} + /> + ) : ( +
    + {visibleAds.map((ad) => ( + void onApprove(ad.id)} + onReject={() => { + setRejectId(ad.id); + setRejectReason(""); + }} + onDelete={() => setDeleteId(ad.id)} + /> + ))} +
+ )} +
+
+ + {/* ── 自助推广配置 ─────────────────────────── */} +
{ + sectionRefs.current.promote = el; + }} + className="panel overflow-hidden" + > + } + title="自助推广" + desc="配置购买页的时长档位与收款方式" + extra={ +
+ + {cfg.enabled ? "已开放" : "已关闭"} + + setCfg({ ...cfg, enabled: !cfg.enabled })} + /> +
+ } + /> +
+
+
+ 侧栏 / 页面标题 + setCfg({ ...cfg, panel_title: e.target.value })} + maxLength={16} + /> +
+
+ + {/* 时长档位 */} + + setCfg({ + ...cfg, + durations: [...cfg.durations, { days: 14, label: "14 天", price_hint: "" }], + }) + } + > + 添加 + + } + > + {cfg.durations.length === 0 ? ( +

至少保留一个时长档位。

+ ) : ( +
+ {cfg.durations.map((d, i) => ( +
+ { + const durations = [...cfg.durations]; + durations[i] = { ...d, days: Number(e.target.value) || 1 }; + setCfg({ ...cfg, durations }); + }} + aria-label="天数" + /> + { + const durations = [...cfg.durations]; + durations[i] = { ...d, label: e.target.value }; + setCfg({ ...cfg, durations }); + }} + /> + +
+ ))} +
+ )} +
+ + {/* 收款方式 */} + + 添加 + + } + > + {cfg.payments.length === 0 ? ( +
+ + 添加支付宝 / 微信收款码后,游客才能提交申购。 +
+ ) : ( +
+ {cfg.payments.map((p, i) => ( + { + const payments = [...cfg.payments]; + payments[i] = next; + setCfg({ ...cfg, payments }); + }} + onUpload={(file) => void uploadQR(i, file)} + onRemove={() => + setCfg({ ...cfg, payments: cfg.payments.filter((_, j) => j !== i) }) + } + /> + ))} +
+ )} +
+ +
+ +
+
+
+ + {/* ── 赞助商(占满内容栏全宽)────────────── */} +
{ + sectionRefs.current.sponsors = el; + }} + className="panel overflow-hidden" + > + } + title="赞助商" + desc="展示在首页侧栏的赞助商 logo 墙" + extra={ +
+ + {sponsors.enabled ? "已展示" : "已隐藏"} + + setSponsors({ ...sponsors, enabled: !sponsors.enabled })} + /> +
+ } + /> +
+
+ 侧栏标题 + setSponsors({ ...sponsors, panel_title: e.target.value })} + maxLength={16} + /> +
+ + + 添加 + + } + > + {sponsors.items.length === 0 ? ( + } + title="暂无赞助商" + description="添加 logo 后,悬停或聚焦可显示赞助说明" + /> + ) : ( +
+ {sponsors.items.map((sp, i) => ( + { + const items = [...sponsors.items]; + items[i] = next; + setSponsors({ ...sponsors, items }); + }} + onUpload={(file) => void uploadSponsorLogo(i, file)} + onRemove={() => + setSponsors({ + ...sponsors, + items: sponsors.items.filter((_, j) => j !== i), + }) + } + /> + ))} +
+ )} +
+ +
+ +
+
+
+
+
+ + void onDelete()} + onCancel={() => setDeleteId(null)} + /> + + { + if (!busy) { + setRejectId(null); + setRejectReason(""); + } + }} + busy={busy} + maxWidthClass="max-w-sm" + footer={ + <> + + + + } + > +