feat: 广告激励(效果图加次/体验会员,服务端限频防刷)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 13:37:57 +08:00
co-authored by Claude Opus 4.7
parent de9b4b7a01
commit 8a47609159
8 changed files with 186 additions and 0 deletions
+5
View File
@@ -46,3 +46,8 @@ payment:
notify_url: "http://localhost:3007/member/order/notify" # 生产需公网可达
channel: "alipay,wechat"
api_base: "https://api.xunhupay.com"
# 广告激励限频(自然日)
ad:
limit_effect_extra: 2
limit_vip_trial: 1
+1
View File
@@ -33,6 +33,7 @@ func main() {
controller.Outfit,
controller.PartnerStore,
controller.Member,
controller.Ad,
})
// 虎皮棋支付回调(裸文本 "success",不走统一 JSON 包装)
+26
View File
@@ -0,0 +1,26 @@
package controller
import (
"context"
commonHttp "slogan-agent/common"
"slogan-agent/styleagent/model/dto"
"slogan-agent/styleagent/service"
"github.com/gogf/gf/v2/frame/g"
)
type ad struct{}
var Ad = new(ad)
// RewardClaim 领取广告激励(限频:effect_extra 每日 2 次 / vip_trial 每日 1 次)
func (c *ad) RewardClaim(ctx context.Context, req *dto.AdRewardClaimReq) (res *dto.AdRewardClaimRes, err error) {
result, err := service.AdService.Claim(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.AdType)
if err != nil {
return nil, err
}
return &dto.AdRewardClaimRes{Reward: &dto.AdRewardInfo{
AdType: result.AdType, RemainingToday: result.RemainingToday,
}}, nil
}
+58
View File
@@ -0,0 +1,58 @@
package dao
import (
"context"
"errors"
"fmt"
"time"
"slogan-agent/styleagent/consts"
"github.com/gogf/gf/v2/frame/g"
)
var AdRewardLog = &adRewardLogDao{}
type adRewardLogDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameAdRewardLog+` (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
ad_type TEXT NOT NULL DEFAULT '',
reward_key TEXT NOT NULL DEFAULT '',
slot INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'ok',
created_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create ad_reward_log table failed: %v", err)
}
// 唯一索引兜底并发:同一 (user, day, type) 最多 limit 个 slot(如 effect_extra 2 / vip_trial 1
_, _ = g.DB().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON `+consts.TableNameAdRewardLog+`(user_id, reward_key, slot)`)
}
// rewardKey 自然日去重粒度:"2026-07-31:effect_extra"
func rewardKey(adType string) string {
return fmt.Sprintf("%s:%s", time.Now().Format("2006-01-02"), adType)
}
func (d *adRewardLogDao) CountTodayByType(ctx context.Context, userId int64, adType string) (int, error) {
n, err := g.DB().Model(consts.TableNameAdRewardLog).Ctx(ctx).
Where("user_id", userId).Where("reward_key", rewardKey(adType)).Count()
return int(n), err
}
// Insert 领取记录:在 1..limit 的 slot 中找一个空闲位写入;全满(唯一索引冲突)返回错误 → 视为限频
func (d *adRewardLogDao) Insert(ctx context.Context, userId int64, adType string, limit int) (int64, error) {
for slot := 1; slot <= limit; slot++ {
r, err := g.DB().Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{
"user_id": userId, "ad_type": adType, "reward_key": rewardKey(adType), "slot": slot, "status": "ok",
}).Insert()
if err == nil {
return r.LastInsertId()
}
}
return 0, errors.New("ad reward quota exhausted")
}
+14
View File
@@ -239,3 +239,17 @@ type MemberOrderStatusRes struct {
TradeNo string `json:"trade_no"`
PaidAt string `json:"paid_at"`
}
type AdRewardClaimReq struct {
g.Meta `path:"/reward/claim" method:"post" tags:"广告" summary:"领取广告激励"`
AdType string `v:"required|in:effect_extra,vip_trial" json:"ad_type"`
}
type AdRewardInfo struct {
AdType string `json:"ad_type"`
RemainingToday int `json:"remaining_today"`
}
type AdRewardClaimRes struct {
Reward *AdRewardInfo `json:"reward"`
}
+1
View File
@@ -7,6 +7,7 @@ type AdRewardLog struct {
UserId int64 `orm:"user_id" json:"user_id"`
AdType string `orm:"ad_type" json:"ad_type"`
RewardKey string `orm:"reward_key" json:"reward_key"`
Slot int `orm:"slot" json:"slot"`
Status string `orm:"status" json:"status"`
CreatedAt *gtime.Time `orm:"created_at" json:"created_at"`
}
+56
View File
@@ -0,0 +1,56 @@
package service
import (
"context"
"errors"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/dao"
"github.com/gogf/gf/v2/frame/g"
)
type adService struct{}
var AdService = new(adService)
type AdRewardResult struct {
AdType string `json:"ad_type"`
RemainingToday int `json:"remaining_today"`
}
// Claim 领取广告激励:服务端限频计数,不信任客户端
func (s *adService) Claim(ctx context.Context, userId int64, adType string) (*AdRewardResult, error) {
if adType != consts.AdTypeEffectExtra && adType != consts.AdTypeVipTrial {
return nil, errors.New("无效的广告类型")
}
limit := rewardQuota(ctx, adType)
used, err := dao.AdRewardLog.CountTodayByType(ctx, userId, adType)
if err != nil {
return nil, err
}
if used >= limit {
return nil, errors.New("今日次数已用完")
}
if _, err := dao.AdRewardLog.Insert(ctx, userId, adType, limit); err != nil {
return nil, errors.New("今日次数已用完") // 唯一索引兜底并发
}
if adType == consts.AdTypeVipTrial {
_ = dao.UserMember.Upsert(ctx, userId, 0, NextExpire(nil, 1), consts.MemberSourceAdTrial)
}
return &AdRewardResult{AdType: adType, RemainingToday: limit - used - 1}, nil
}
func rewardQuota(ctx context.Context, adType string) int {
if adType == consts.AdTypeVipTrial {
return g.Cfg().MustGet(ctx, "ad.limit_vip_trial", 1).Int()
}
return g.Cfg().MustGet(ctx, "ad.limit_effect_extra", 2).Int()
}
func rewardRemaining(limit, used int) int {
if r := limit - used; r > 0 {
return r
}
return 0
}
+25
View File
@@ -0,0 +1,25 @@
package service
import (
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
)
func TestRewardRemaining(t *testing.T) {
if got := rewardRemaining(2, 0); got != 2 {
t.Fatalf("未领取时剩余应为 2, got %d", got)
}
if got := rewardRemaining(2, 2); got != 0 {
t.Fatalf("已用满时剩余应为 0, got %d", got)
}
if got := rewardRemaining(1, 1); got != 0 {
t.Fatalf("vip_trial 已用完剩余应为 0, got %d", got)
}
}
func TestRewardQuota(t *testing.T) {
if got := rewardQuota(t.Context(), "effect_extra"); got != 2 {
t.Fatalf("默认每日 2 次, got %d", got)
}
}