diff --git a/config.yml b/config.yml index ad278bc..7901109 100644 --- a/config.yml +++ b/config.yml @@ -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 diff --git a/main.go b/main.go index 8a72ad0..c0831c4 100644 --- a/main.go +++ b/main.go @@ -33,6 +33,7 @@ func main() { controller.Outfit, controller.PartnerStore, controller.Member, + controller.Ad, }) // 虎皮棋支付回调(裸文本 "success",不走统一 JSON 包装) diff --git a/styleagent/controller/ad_controller.go b/styleagent/controller/ad_controller.go new file mode 100644 index 0000000..b5387c4 --- /dev/null +++ b/styleagent/controller/ad_controller.go @@ -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 +} diff --git a/styleagent/dao/ad_reward_log_dao.go b/styleagent/dao/ad_reward_log_dao.go new file mode 100644 index 0000000..e0e01fd --- /dev/null +++ b/styleagent/dao/ad_reward_log_dao.go @@ -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") +} diff --git a/styleagent/model/dto/dto.go b/styleagent/model/dto/dto.go index 64dc5c1..ae44a0a 100644 --- a/styleagent/model/dto/dto.go +++ b/styleagent/model/dto/dto.go @@ -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"` +} diff --git a/styleagent/model/entity/ad_reward_log.go b/styleagent/model/entity/ad_reward_log.go index 37ce439..bd66fb1 100644 --- a/styleagent/model/entity/ad_reward_log.go +++ b/styleagent/model/entity/ad_reward_log.go @@ -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"` } diff --git a/styleagent/service/ad_service.go b/styleagent/service/ad_service.go new file mode 100644 index 0000000..0bff724 --- /dev/null +++ b/styleagent/service/ad_service.go @@ -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 +} diff --git a/styleagent/service/ad_service_test.go b/styleagent/service/ad_service_test.go new file mode 100644 index 0000000..e59fce7 --- /dev/null +++ b/styleagent/service/ad_service_test.go @@ -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) + } +}