feat(payments): edit the rewarded-ads config from the admin catalog page #262
@@ -21,6 +21,15 @@
|
||||
<div><button type="submit">Add</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Rewarded ads (watch for chips)</h2>
|
||||
<p class="note">The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — <strong>0 payout turns rewarded off</strong>. This is the shared reward config, not a product.</p>
|
||||
<form class="form col" method="post" action="/_gm/catalog/reward">
|
||||
<label>Chips per view <input type="number" name="reward_payout" min="0" value="{{.RewardPayout}}"></label>
|
||||
<label>Daily cap <input type="number" name="reward_daily" min="0" value="{{.RewardDailyCap}}"></label>
|
||||
<label>Hourly cap <input type="number" name="reward_hourly" min="0" value="{{.RewardHourlyCap}}"></label>
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Products</h2>
|
||||
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
|
||||
<table class="list">
|
||||
|
||||
@@ -680,6 +680,12 @@ type CatalogView struct {
|
||||
// ShowAll reports whether archived products are listed alongside active ones (the active/all
|
||||
// toggle); it drives the toggle link and the list heading.
|
||||
ShowAll bool
|
||||
// RewardPayout / RewardDailyCap / RewardHourlyCap are the rewarded-video config (chips earned per
|
||||
// view and the per-day / per-hour anti-abuse caps), shown in and edited from the page's
|
||||
// rewarded-ads form. A 0 payout means rewarded is inert.
|
||||
RewardPayout int
|
||||
RewardDailyCap int
|
||||
RewardHourlyCap int
|
||||
}
|
||||
|
||||
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
||||
|
||||
@@ -124,3 +124,30 @@ func TestConsoleCatalogActiveToggle(t *testing.T) {
|
||||
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleRewardConfig checks the rewarded-ads config form on the catalog page: a POST updates the
|
||||
// payout and caps, the page pre-fills the current values, and a negative value is refused.
|
||||
func TestConsoleRewardConfig(t *testing.T) {
|
||||
srv, _, pay := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
const origin = "http://admin.test"
|
||||
const reward = "http://admin.test/_gm/catalog/reward"
|
||||
|
||||
// Set the payout and caps.
|
||||
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=7&reward_daily=40&reward_hourly=9", origin); code != http.StatusOK || !strings.Contains(body, "Saved") {
|
||||
t.Fatalf("set reward = %d, has 'Saved' = %v", code, strings.Contains(body, "Saved"))
|
||||
}
|
||||
if payout, daily, hourly, err := pay.RewardConfig(context.Background()); err != nil || payout != 7 || daily != 40 || hourly != 9 {
|
||||
t.Fatalf("reward config = %d/%d/%d (err %v), want 7/40/9", payout, daily, hourly, err)
|
||||
}
|
||||
|
||||
// The catalog page renders the form pre-filled with the current payout.
|
||||
if _, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/catalog", "", ""); !strings.Contains(body, "reward_payout") || !strings.Contains(body, `value="7"`) {
|
||||
t.Error("catalog page did not render the reward form with the current payout")
|
||||
}
|
||||
|
||||
// A negative value is refused (the service validates non-negativity).
|
||||
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=-1", origin); code != http.StatusOK || !strings.Contains(body, "non-negative") {
|
||||
t.Errorf("negative payout = %d, has 'non-negative' = %v", code, strings.Contains(body, "non-negative"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,22 @@ func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Sourc
|
||||
return payout, err
|
||||
}
|
||||
|
||||
// RewardConfig reads the rewarded-video config for the admin editor: the payout (chips per view) and
|
||||
// the per-day and per-hour caps.
|
||||
func (s *Service) RewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
|
||||
return s.store.rewardConfig(ctx)
|
||||
}
|
||||
|
||||
// SetRewardConfig updates the rewarded-video config (the payout and the two caps). All three must be
|
||||
// non-negative; a 0 payout leaves rewarded inert. It is not a catalog product, so it does not mark the
|
||||
// offer stale.
|
||||
func (s *Service) SetRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||
if payout < 0 || dailyCap < 0 || hourlyCap < 0 {
|
||||
return errors.New("payments: reward payout and caps must be non-negative")
|
||||
}
|
||||
return s.store.setRewardConfig(ctx, payout, dailyCap, hourlyCap)
|
||||
}
|
||||
|
||||
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
||||
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
||||
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
||||
|
||||
@@ -413,6 +413,18 @@ func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap i
|
||||
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
||||
}
|
||||
|
||||
// setRewardConfig updates the rewarded payout and the per-day and per-hour caps on the singleton
|
||||
// config row (no WHERE — the table holds exactly one row). Non-negativity is validated by the caller
|
||||
// and also enforced by the config CHECK constraints.
|
||||
func (s *Store) setRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
|
||||
payout, dailyCap, hourlyCap); err != nil {
|
||||
return fmt.Errorf("payments: set reward config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
||||
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
||||
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
||||
|
||||
@@ -46,13 +46,32 @@ func (s *Server) consoleCatalog(c *gin.Context) {
|
||||
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
||||
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
||||
payments.SortAdminCatalog(products)
|
||||
view := adminconsole.CatalogView{ShowAll: showAll}
|
||||
payout, daily, hourly, err := s.payments.RewardConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
|
||||
for _, p := range products {
|
||||
view.Products = append(view.Products, catalogRow(p))
|
||||
}
|
||||
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
||||
}
|
||||
|
||||
// consoleSetReward updates the rewarded-video config (chips per view plus the per-day and per-hour
|
||||
// caps) from the catalog page's rewarded-ads form. A blank field reads as 0; a negative value is
|
||||
// refused by the service.
|
||||
func (s *Server) consoleSetReward(c *gin.Context) {
|
||||
payout, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_payout")))
|
||||
daily, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_daily")))
|
||||
hourly, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_hourly")))
|
||||
if err := s.payments.SetRewardConfig(c.Request.Context(), payout, daily, hourly); err != nil {
|
||||
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Saved", "the rewarded-ads config was updated", catalogBack)
|
||||
}
|
||||
|
||||
// catalogRow projects a product into its list row.
|
||||
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
||||
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
||||
|
||||
@@ -112,6 +112,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
if s.payments != nil {
|
||||
gm.GET("/catalog", s.consoleCatalog)
|
||||
gm.POST("/catalog", s.consoleCreateProduct)
|
||||
gm.POST("/catalog/reward", s.consoleSetReward)
|
||||
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||
|
||||
@@ -201,7 +201,9 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
|
||||
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
|
||||
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
|
||||
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге.
|
||||
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге. (Ставка
|
||||
rewarded + дневной/часовой потолки — строка общего конфига `payments.config`, правится в
|
||||
секции «Rewarded ads» на странице каталога админки; это не продаваемый продукт-атом.)
|
||||
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
|
||||
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
|
||||
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
|
||||
|
||||
Reference in New Issue
Block a user