feat: add ads/sponsor/captcha modules and polish home & chat UI
This commit is contained in:
253
backend/handler/ad.go
Normal file
253
backend/handler/ad.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -33,6 +33,7 @@ type Handlers struct {
|
||||
Telemetry *service.TelemetryService
|
||||
Analytics *service.AnalyticsService
|
||||
HidePwd *service.HidePasswordCookie
|
||||
Ads *service.AdService
|
||||
}
|
||||
|
||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
44
backend/model/ad.go
Normal file
44
backend/model/ad.go
Normal file
@@ -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:"-"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
580
backend/service/ad.go
Normal file
580
backend/service/ad.go
Normal file
@@ -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
|
||||
}
|
||||
236
backend/service/captcha.go
Normal file
236
backend/service/captcha.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
198
backend/service/sponsor.go
Normal file
198
backend/service/sponsor.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user