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 {initError} 至少保留一个时长档位。
+ {title}
+
+ {ad.kind === "text" ? ad.title : ad.image_url}
+
+ {ad.contact_email}
+ {ad.link_url ? (
+ <>
+ {" · "}
+
+ {ad.link_url}
+
+ >
+ ) : null}
+
+ 拒绝:{ad.reject_reason}
+
+ 提交于 {formatRelative(ad.created_at)}
+ {ad.ends_at ? ` · 到期 ${formatRelative(ad.ends_at)}` : null}
+
+ {visibleAds.map((ad) => (
+
+ )}
+
+ ) : ad.kind === "text" && ad.title ? (
+
+ {loadErr} +
+ + 返回首页 + +加载中…
++ 自助推广暂未开放 +
+ + 返回首页 + ++ 请完成付款,管理员确认后将出现在首页侧栏。 +
+ {selectedPay?.qr_url ? ( + + ) : null} ++ 上线预览 · 尺寸与首页侧栏一致 +
+