feat: 广告激励(效果图加次/体验会员,服务端限频防刷)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -33,6 +33,7 @@ func main() {
|
||||
controller.Outfit,
|
||||
controller.PartnerStore,
|
||||
controller.Member,
|
||||
controller.Ad,
|
||||
})
|
||||
|
||||
// 虎皮棋支付回调(裸文本 "success",不走统一 JSON 包装)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user