From c1f6a6636bd03b1bf8c9024f6420eb19da9ecc7c Mon Sep 17 00:00:00 2001
From: freefire
Date: Wed, 23 Sep 2026 04:39:58 +0800
Subject: [PATCH] feat: add ads/sponsor/captcha modules and polish home & chat
UI
---
backend/handler/ad.go | 253 ++++
backend/handler/handlers.go | 1 +
backend/handler/overview.go | 6 +
backend/model/ad.go | 44 +
backend/model/db.go | 29 +-
backend/router/router.go | 19 +
backend/service/ad.go | 580 +++++++++
backend/service/captcha.go | 236 ++++
backend/service/overview.go | 64 +-
backend/service/ratelimit.go | 4 +
backend/service/sponsor.go | 198 ++++
backend/service/upload.go | 94 +-
frontend/app/admin/ads/AdsAdmin.tsx | 1103 ++++++++++++++++++
frontend/app/admin/ads/page.tsx | 56 +
frontend/app/ads/buy/AdsBuyClient.tsx | 574 +++++++++
frontend/app/ads/buy/page.tsx | 15 +
frontend/app/chat/ChatShell.tsx | 9 +-
frontend/app/chat/[id]/ChatRoomClient.tsx | 21 +-
frontend/app/globals.css | 629 ++++++++--
frontend/app/page.tsx | 12 +
frontend/components/AdText.tsx | 73 ++
frontend/components/CommentSection.tsx | 2 +-
frontend/components/Header.tsx | 10 +-
frontend/components/admin/AdminShell.tsx | 11 +-
frontend/components/home/CommunityPanels.tsx | 163 +++
frontend/lib/ads.ts | 8 +
frontend/lib/api.ts | 185 +++
27 files changed, 4278 insertions(+), 121 deletions(-)
create mode 100644 backend/handler/ad.go
create mode 100644 backend/model/ad.go
create mode 100644 backend/service/ad.go
create mode 100644 backend/service/captcha.go
create mode 100644 backend/service/sponsor.go
create mode 100644 frontend/app/admin/ads/AdsAdmin.tsx
create mode 100644 frontend/app/admin/ads/page.tsx
create mode 100644 frontend/app/ads/buy/AdsBuyClient.tsx
create mode 100644 frontend/app/ads/buy/page.tsx
create mode 100644 frontend/components/AdText.tsx
create mode 100644 frontend/lib/ads.ts
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)}
+ />
+ ))}
+
+ )}
+
+
+
+ {/* ── 自助推广配置 ─────────────────────────── */}
+
+
+ {/* ── 赞助商(占满内容栏全宽)────────────── */}
+
+
+
+
+ void onDelete()}
+ onCancel={() => setDeleteId(null)}
+ />
+
+ {
+ if (!busy) {
+ setRejectId(null);
+ setRejectReason("");
+ }
+ }}
+ busy={busy}
+ maxWidthClass="max-w-sm"
+ footer={
+ <>
+
+
+ >
+ }
+ >
+
+
+
+ );
+}
+
+/* ─── 通用分块:卡片头 ─── */
+function SectionHead({
+ icon,
+ title,
+ desc,
+ extra,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ desc?: string;
+ extra?: React.ReactNode;
+}) {
+ return (
+
+
+
+ {icon}
+
+
+
+ {title}
+
+ {desc ? (
+ {desc}
+ ) : null}
+
+
+ {extra ?
{extra}
: null}
+
+ );
+}
+
+/* ─── 子区块:带标题与右侧操作 ─── */
+function SubBlock({
+ title,
+ action,
+ children,
+}: {
+ title: string;
+ action?: React.ReactNode;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {title}
+
+ {action}
+
+ {children}
+
+ );
+}
+
+/* ─── 申购行:缩略+信息在左,操作在右(始终相邻) ─── */
+function AdCard({
+ ad,
+ busy,
+ onApprove,
+ onReject,
+ onDelete,
+}: {
+ ad: AdRecord;
+ busy: boolean;
+ onApprove: () => void;
+ onReject: () => void;
+ onDelete: () => void;
+}) {
+ const tone =
+ ad.status === "active"
+ ? "ok"
+ : ad.status === "pending"
+ ? "warn"
+ : ad.status === "rejected"
+ ? "danger"
+ : "muted";
+ const canApprove = ad.status === "pending" || ad.status === "rejected" || ad.status === "expired";
+ const canReject = ad.status === "pending" || ad.status === "active";
+
+ return (
+
+ {/* 预览缩略 */}
+
+ {ad.kind === "image" && ad.image_url ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : ad.kind === "text" && ad.title ? (
+
+ ) : (
+
无预览
+ )}
+
+
+ {/* 信息 */}
+
+
+
+ {statusLabel[ad.status] || ad.status}
+
+
+ {ad.kind === "image" ? : }
+ {ad.kind === "image" ? "图片" : "文字"} · {ad.duration_days} 天
+
+
+
+ {ad.kind === "text" ? ad.title : ad.image_url}
+
+
+ {ad.contact_email}
+ {ad.link_url ? (
+ <>
+ {" · "}
+
+ {ad.link_url}
+
+ >
+ ) : null}
+
+ {ad.reject_reason ? (
+
+ 拒绝:{ad.reject_reason}
+
+ ) : (
+
+ 提交于 {formatRelative(ad.created_at)}
+ {ad.ends_at ? ` · 到期 ${formatRelative(ad.ends_at)}` : null}
+
+ )}
+
+
+ {/* 操作:与信息同行右对齐,窄屏换行 */}
+
+ {canApprove && (
+
+ )}
+ {canReject && (
+
+ )}
+
+
+
+ );
+}
+
+/* ─── 收款方式编辑器:二维码居左可点传,字段居右 ─── */
+const KIND_META: Record = {
+ alipay: { label: "支付宝", color: "#1677FF" },
+ wechat: { label: "微信", color: "#07C160" },
+ other: { label: "其他", color: "var(--ink-3)" },
+};
+
+function PaymentEditor({
+ payment,
+ onChange,
+ onUpload,
+ onRemove,
+}: {
+ payment: AdPaymentOption;
+ onChange: (p: AdPaymentOption) => void;
+ onUpload: (file: File) => void;
+ onRemove: () => void;
+}) {
+ const meta = KIND_META[payment.kind] || KIND_META.other;
+
+ return (
+
+ {/* 二维码:点击即上传 */}
+
+
+ {/* 字段区 */}
+
+
+
+
+ {meta.label}
+
+
onChange({ ...payment, name: e.target.value })}
+ placeholder="收款方式名称,如:站长支付宝"
+ />
+
+
+
+
+
+
+
onChange({ ...payment, hint: e.target.value })}
+ placeholder="付款提示文案,如:付款请备注邮箱,便于核对"
+ />
+
onChange({ ...payment, qr_url: e.target.value })}
+ placeholder="或直接填写 https 图片地址(已上传可留空)"
+ />
+
+
+ );
+}
+
+/* ─── 赞助商编辑器:全宽行,Logo 左 / 字段中 / 操作最右 ─── */
+function SponsorEditor({
+ item,
+ onChange,
+ onUpload,
+ onRemove,
+}: {
+ item: SponsorItem;
+ onChange: (p: SponsorItem) => void;
+ onUpload: (file: File) => void;
+ onRemove: () => void;
+}) {
+ return (
+
+ {/* Logo:点击即上传 */}
+
+
+ {/* 字段区:输入框拉满,操作钉最右 */}
+
+
+
onChange({ ...item, name: e.target.value })}
+ placeholder="赞助商名称"
+ maxLength={32}
+ />
+
+
+
+
+
+
onChange({ ...item, link_url: e.target.value })}
+ placeholder="链接(可选,带 https)"
+ />
+
+
+ );
+}
diff --git a/frontend/app/admin/ads/page.tsx b/frontend/app/admin/ads/page.tsx
new file mode 100644
index 0000000..67304a4
--- /dev/null
+++ b/frontend/app/admin/ads/page.tsx
@@ -0,0 +1,56 @@
+import type { Metadata } from "next";
+import { Suspense } from "react";
+import { cookies } from "next/headers";
+import AdsAdmin from "./AdsAdmin";
+import { authCookieHeader } from "@/lib/cookies";
+import {
+ fetchAdminAds,
+ fetchAdminAdsConfig,
+ fetchAdminSponsorsConfig,
+ type AdConfig,
+ type AdRecord,
+ type SponsorsConfig,
+} from "@/lib/api";
+import { getMeCached } from "@/lib/serverData";
+import { isAdminOrAbove } from "@/lib/roles";
+import Forbidden from "../Forbidden";
+
+export const metadata: Metadata = {
+ title: "推广与赞助",
+};
+
+export default async function AdminAdsPage() {
+ const cookie = authCookieHeader(await cookies());
+ const me = await getMeCached(cookie || undefined);
+ if (!me.user || !isAdminOrAbove(me.user.role)) {
+ return ;
+ }
+
+ let ads: AdRecord[] = [];
+ let cfg: AdConfig | null = null;
+ let sponsors: SponsorsConfig | null = null;
+ let initError = "";
+ try {
+ const [list, config, sp] = await Promise.all([
+ fetchAdminAds(cookie || undefined),
+ fetchAdminAdsConfig(cookie || undefined),
+ fetchAdminSponsorsConfig(cookie || undefined),
+ ]);
+ ads = list.ads ?? [];
+ cfg = config;
+ sponsors = sp;
+ } catch (e) {
+ initError = e instanceof Error ? e.message : "加载失败";
+ }
+
+ return (
+ 加载中…
}>
+
+
+ );
+}
diff --git a/frontend/app/ads/buy/AdsBuyClient.tsx b/frontend/app/ads/buy/AdsBuyClient.tsx
new file mode 100644
index 0000000..f39ea1e
--- /dev/null
+++ b/frontend/app/ads/buy/AdsBuyClient.tsx
@@ -0,0 +1,574 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import {
+ RefreshCw,
+ ImageIcon,
+ Type,
+ CheckCircle2,
+ Mail,
+ Link2,
+ Shield,
+ AlertCircle,
+} from "lucide-react";
+import {
+ apiAdsPublicConfig,
+ apiFetchCaptcha,
+ apiSubmitAd,
+ type AdConfig,
+ type AdPaymentOption,
+} from "@/lib/api";
+import ImageLightbox, { type LightboxItem } from "@/components/markdown/ImageLightbox";
+import { AdText } from "@/components/AdText";
+import { toast } from "@/lib/toast";
+import {
+ AD_IMAGE_HEIGHT,
+ AD_IMAGE_SIZE_LABEL,
+ AD_IMAGE_SIZE_LABEL_2X,
+ AD_IMAGE_WIDTH,
+ AD_TEXT_COL_WIDTH,
+} from "@/lib/ads";
+
+type Kind = "image" | "text";
+
+export default function AdsBuyClient() {
+ const [cfg, setCfg] = useState(null);
+ const [loadErr, setLoadErr] = useState("");
+ const [kind, setKind] = useState("image");
+ const [email, setEmail] = useState("");
+ const [link, setLink] = useState("");
+ const [imageURL, setImageURL] = useState("");
+ const [title, setTitle] = useState("");
+ const [textColor, setTextColor] = useState("#1E293B");
+ const [bgColor, setBgColor] = useState("#F1F5F9");
+ const [days, setDays] = useState(0);
+ const [paymentID, setPaymentID] = useState("");
+ const [note, setNote] = useState("");
+ const [captchaID, setCaptchaID] = useState("");
+ const [captchaImg, setCaptchaImg] = useState("");
+ const [captchaCode, setCaptchaCode] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [done, setDone] = useState(false);
+ const [lightbox, setLightbox] = useState<{ items: LightboxItem[]; index: number } | null>(null);
+
+ const refreshCaptcha = useCallback(async () => {
+ try {
+ const c = await apiFetchCaptcha();
+ setCaptchaID(c.id);
+ setCaptchaImg(c.image);
+ setCaptchaCode("");
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "验证码加载失败", "error");
+ }
+ }, []);
+
+ useEffect(() => {
+ let alive = true;
+ apiAdsPublicConfig()
+ .then((c) => {
+ if (!alive) return;
+ setCfg(c);
+ if (c.durations[0]) setDays(c.durations[0].days);
+ const pay = c.payments.find((p) => p.enabled && p.qr_url);
+ if (pay) setPaymentID(pay.id);
+ })
+ .catch((e) => {
+ if (alive) setLoadErr(e instanceof Error ? e.message : "加载失败");
+ });
+ void refreshCaptcha();
+ return () => {
+ alive = false;
+ };
+ }, [refreshCaptcha]);
+
+ const selectedPay: AdPaymentOption | undefined = cfg?.payments.find((p) => p.id === paymentID);
+ const selectedDuration = cfg?.durations.find((d) => d.days === days);
+ const canSubmit = !!cfg && cfg.payments.length > 0 && !busy;
+ const kindLabel = kind === "image" ? "图片广告" : "文字广告";
+
+ const openLightbox = (src: string, alt = "") => {
+ setLightbox({ items: [{ src, alt }], index: 0 });
+ };
+
+ const onSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!canSubmit) return;
+ setBusy(true);
+ try {
+ await apiSubmitAd({
+ kind,
+ contact_email: email,
+ link_url: link,
+ image_url: kind === "image" ? imageURL : "",
+ title: kind === "text" ? title : "",
+ text_color: kind === "text" ? textColor : "",
+ bg_color: kind === "text" ? bgColor : "",
+ duration_days: days,
+ payment_id: paymentID,
+ buyer_note: note,
+ captcha_id: captchaID,
+ captcha_code: captchaCode,
+ });
+ setDone(true);
+ toast("提交成功,请完成付款并等待审核", "ok");
+ } catch (err) {
+ toast(err instanceof Error ? err.message : "提交失败", "error");
+ void refreshCaptcha();
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (loadErr) {
+ return (
+
+
+
+ {loadErr}
+
+
+ 返回首页
+
+
+
+ );
+ }
+
+ if (!cfg) {
+ return (
+
+
+
+ );
+ }
+
+ if (!cfg.enabled) {
+ return (
+
+
+
+ 自助推广暂未开放
+
+
+ 返回首页
+
+
+
+ );
+ }
+
+ if (done) {
+ return (
+
+
+
+
申请已提交
+
+
+
+
+ 请完成付款,管理员确认后将出现在首页侧栏。
+
+ {selectedPay?.qr_url ? (
+
+ ) : null}
+
+
+ 返回首页
+
+
+
+
+
+ {lightbox ? (
+ setLightbox(null)}
+ onIndexChange={(i) => setLightbox((prev) => (prev ? { ...prev, index: i } : prev))}
+ />
+ ) : null}
+
+ );
+ }
+
+ return (
+
+
+
+ {lightbox ? (
+ setLightbox(null)}
+ onIndexChange={(i) => setLightbox((prev) => (prev ? { ...prev, index: i } : prev))}
+ />
+ ) : null}
+
+ );
+}
+
+function Shell({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+function ChipTab({
+ active,
+ onClick,
+ children,
+ compact,
+}: {
+ active: boolean;
+ onClick: () => void;
+ children: React.ReactNode;
+ compact?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function IconField({
+ icon,
+ children,
+ className = "",
+}: {
+ icon: React.ReactNode;
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * 上线预览:复用首页侧栏 .j13-ad-promo 结构与样式,
+ * 图片位 248×60、文字位两列网格,尺寸/圆角与侧栏完全一致。
+ */
+function AdPreview({
+ kind,
+ imageURL,
+ title,
+ textColor,
+ bgColor,
+ onImageClick,
+}: {
+ kind: Kind;
+ imageURL: string;
+ title: string;
+ textColor: string;
+ bgColor: string;
+ onImageClick: () => void;
+}) {
+ const text = title.trim();
+ return (
+
+
+ 上线预览 · 尺寸与首页侧栏一致
+
+
+ {kind === "image" ? (
+
+ ) : (
+
+ -
+ {text.length > 0 ? (
+
+ ) : (
+ 文字广告位
+ )}
+
+
+ )}
+
+
+ );
+}
+
+function normalizeHex(v: string): string {
+ const s = v.trim();
+ if (/^#[0-9A-Fa-f]{6}$/.test(s)) return s;
+ if (/^[0-9A-Fa-f]{6}$/.test(s)) return `#${s}`;
+ return "#1E293B";
+}
diff --git a/frontend/app/ads/buy/page.tsx b/frontend/app/ads/buy/page.tsx
new file mode 100644
index 0000000..ee27794
--- /dev/null
+++ b/frontend/app/ads/buy/page.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from "next";
+import AdsBuyClient from "./AdsBuyClient";
+import { requestCanonical } from "@/lib/canonicalMeta";
+
+export async function generateMetadata(): Promise {
+ return {
+ title: "自助购买广告位",
+ description: "在首页侧栏投放图片或文字推广,支持游客申购。",
+ ...(await requestCanonical("/ads/buy")),
+ };
+}
+
+export default function AdsBuyPage() {
+ return ;
+}
diff --git a/frontend/app/chat/ChatShell.tsx b/frontend/app/chat/ChatShell.tsx
index e3dae59..1851b06 100644
--- a/frontend/app/chat/ChatShell.tsx
+++ b/frontend/app/chat/ChatShell.tsx
@@ -738,8 +738,7 @@ function CreateGroupModal({
onChange={(e) => setName(e.target.value)}
maxLength={32}
placeholder="2-32 个字符"
- className="w-full px-3.5 h-10 rounded-lg outline-none"
- style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
+ className="field"
/>
@@ -751,8 +750,7 @@ function CreateGroupModal({
onChange={(e) => setDescription(e.target.value)}
maxLength={200}
rows={3}
- className="w-full px-3.5 py-2.5 rounded-lg outline-none resize-none"
- style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
+ className="field resize-none"
/>
@@ -796,8 +794,7 @@ function CreateGroupModal({
value={memberInput}
onChange={(e) => setMemberInput(e.target.value)}
placeholder="用户名,多个用空格或逗号分隔"
- className="w-full px-3.5 h-10 rounded-lg outline-none"
- style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
+ className="field"
/>
)}
diff --git a/frontend/app/chat/[id]/ChatRoomClient.tsx b/frontend/app/chat/[id]/ChatRoomClient.tsx
index 3c38a5e..74afac6 100644
--- a/frontend/app/chat/[id]/ChatRoomClient.tsx
+++ b/frontend/app/chat/[id]/ChatRoomClient.tsx
@@ -1856,12 +1856,7 @@ function InvitePanel({
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="username1, username2"
- className="w-full px-3.5 h-10 rounded-lg outline-none"
- style={{
- background: "var(--bg)",
- border: "1px solid var(--line)",
- color: "var(--ink)",
- }}
+ className="field"
/>