feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Interstitial video after a confirmed play or a hint, VK-only, offline banner-only. The gate is client-mirrored: the backend puts the config cooldowns (global 5m / vs_ai 30m / hint 1m) and a suppressed flag (the no-ads / no_banner gate, same as the banner) on Profile.ads via adsFor; the client self-gates on a per-kind last-shown time in localStorage, with the VITE_ADS_STUB contour "ad fired" toast. Never fires after a pass, exchange or resign. maybeShowInterstitial + vkShowInterstitial; the codec and gateway transcode carry the ads block. Also retires the deprecated accounts.hint_balance / paid_account domain usage (expand-contract, code only — the columns stay for a later DROP so image rollback stays DB-safe): drop the Account fields and their scan, the dead account.SpendHint, account.GrantHints and the admin grant-hints action; the in-game hint display now comes wholly from the payments hint benefit. Banner eligibility and account merge no longer read the legacy flags. Tests: ads.test.ts (the client-mirrored gate), the codec ads round-trip, the gateway ads transcode test, and a profile ads-config integration test (cooldowns + suppressed under no-ads / no_banner). Docs: PAYMENTS §10 (+ru) interstitial, the decision amends, backend README, the plan.
This commit is contained in:
+8
-6
@@ -135,21 +135,23 @@ so `/internal/push-target` returns the recipient's `preferred_language` as the r
|
||||
language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
|
||||
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
|
||||
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
|
||||
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
|
||||
&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs
|
||||
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire
|
||||
the resolved, weighted campaign feed for an **eligible** viewer (no active **no-ads** benefit
|
||||
applicable in the current context and no **`no_banner`** role; the message language picked by
|
||||
`preferred_language`); changing those inputs
|
||||
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place.
|
||||
The same gate drives the post-move interstitial config (`Profile.ads`, `adsFor`). The shared wire
|
||||
contracts live in the sibling [`../pkg`](../pkg) module.
|
||||
|
||||
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
|
||||
attached to the current account, and when the identity already has its own account
|
||||
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
|
||||
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred,
|
||||
the two are merged in one transaction (`internal/accountmerge`) — stats summed,
|
||||
identities/games/chat/complaints transferred,
|
||||
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a
|
||||
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
|
||||
The current account is primary, except a guest initiator whose linked identity has a
|
||||
durable owner — then the durable account wins and a fresh session is minted for it.
|
||||
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
|
||||
The `accounts.merged_into`/`merged_at` columns back this. This supersedes the
|
||||
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
|
||||
|
||||
Rate-limit observability: the gateway posts its periodic rejection
|
||||
|
||||
@@ -43,9 +43,7 @@ var ErrNotFound = errors.New("account: not found")
|
||||
// local-time window (in TimeZone) during which the player is asleep, so the
|
||||
// turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's
|
||||
// own sleep is anchored to its human opponent's timezone with a per-game drift,
|
||||
// computed in internal/robot, not from a robot account's away window.) HintBalance
|
||||
// is the player's wallet of purchasable hints, spent after a game's per-seat
|
||||
// allowance.
|
||||
// computed in internal/robot, not from a robot account's away window.)
|
||||
type Account struct {
|
||||
ID uuid.UUID
|
||||
DisplayName string
|
||||
@@ -53,7 +51,6 @@ type Account struct {
|
||||
TimeZone string
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
HintBalance int
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
// VariantPreferences is the set of game variants (engine.Variant stable labels:
|
||||
@@ -69,10 +66,6 @@ type Account struct {
|
||||
// true (the default): the platform side-service skips out-of-app push for the
|
||||
// account.
|
||||
NotificationsInAppOnly bool
|
||||
// PaidAccount marks a lifetime one-time-payment account. It is a service field
|
||||
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
|
||||
// never lost when accounts are consolidated.
|
||||
PaidAccount bool
|
||||
// MergedInto is the primary account a retired (merged) secondary points at, or
|
||||
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
|
||||
// foreign keys of a shared finished game stay valid.
|
||||
@@ -563,52 +556,6 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account,
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// SpendHint atomically decrements the account's hint wallet by one, returning
|
||||
// true when a hint was spent and false when the balance was already empty. The
|
||||
// guarded UPDATE keeps it safe under concurrent spends across the player's games.
|
||||
func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
|
||||
SET(table.Accounts.HintBalance.SUB(postgres.Int(1)), postgres.TimestampzT(time.Now().UTC())).
|
||||
WHERE(
|
||||
table.Accounts.AccountID.EQ(postgres.UUID(id)).
|
||||
AND(table.Accounts.HintBalance.GT(postgres.Int(0))),
|
||||
)
|
||||
res, err := stmt.ExecContext(ctx, s.db)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: spend hint %s: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("account: spend hint rows %s: %w", id, err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// GrantHints adds n hints to the account's wallet and returns the new balance. n must be
|
||||
// positive: the additive update can only raise the balance, never lower it, so it enforces the
|
||||
// admin console's raise-only rule by construction and stays correct under a concurrent SpendHint.
|
||||
// It returns ErrNotFound when no account matches.
|
||||
func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) {
|
||||
if n <= 0 {
|
||||
return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n)
|
||||
}
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
|
||||
SET(table.Accounts.HintBalance.ADD(postgres.Int(int64(n))), postgres.TimestampzT(time.Now().UTC())).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||
RETURNING(table.Accounts.HintBalance)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
return 0, fmt.Errorf("account: grant hints %s: %w", id, err)
|
||||
}
|
||||
return int(row.HintBalance), nil
|
||||
}
|
||||
|
||||
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
|
||||
// the account is not already flagged — the first sustained episode wins, and a
|
||||
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not
|
||||
@@ -664,12 +611,10 @@ func modelToAccount(row model.Accounts) Account {
|
||||
TimeZone: row.TimeZone,
|
||||
AwayStart: row.AwayStart,
|
||||
AwayEnd: row.AwayEnd,
|
||||
HintBalance: int(row.HintBalance),
|
||||
BlockChat: row.BlockChat,
|
||||
BlockFriendRequests: row.BlockFriendRequests,
|
||||
IsGuest: row.IsGuest,
|
||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||
PaidAccount: row.PaidAccount,
|
||||
MergedInto: mergedInto,
|
||||
FlaggedHighRateAt: flaggedHighRateAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
<li><b>Timezone</b> {{.TimeZone}}</li>
|
||||
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
|
||||
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
||||
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
|
||||
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
|
||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||
@@ -21,10 +19,6 @@
|
||||
<button type="submit">Clear high-rate flag</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form class="form" method="post" action="/_gm/users/{{.ID}}/grant-hints">
|
||||
<label>Add hints <input type="number" name="amount" min="1" max="{{.HintGrantMax}}" value="1"></label>
|
||||
<button type="submit">Grant</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Statistics</h2>
|
||||
{{if .HasStats}}
|
||||
|
||||
@@ -149,7 +149,6 @@ type UserDetailView struct {
|
||||
TimeZone string
|
||||
Guest bool
|
||||
NotificationsInAppOnly bool
|
||||
PaidAccount bool
|
||||
// MergedInto is the primary account id when this account has been retired by a
|
||||
// merge, or empty for a live account.
|
||||
MergedInto string
|
||||
@@ -165,14 +164,10 @@ type UserDetailView struct {
|
||||
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
||||
// empty for an unflagged account; the card shows it with the Clear action.
|
||||
FlaggedHighRateAt string
|
||||
HintBalance int
|
||||
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
|
||||
// server's maxHintGrant), passed through so the policy value lives in one place.
|
||||
HintGrantMax int
|
||||
CreatedAt string
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
CreatedAt string
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
// HasEmail gates the "Erase email" action; set when the account carries an email identity.
|
||||
HasEmail bool
|
||||
Games []GameRow
|
||||
|
||||
@@ -1272,10 +1272,6 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
if !ok {
|
||||
return StateView{}, ErrNotAPlayer
|
||||
}
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return StateView{}, err
|
||||
}
|
||||
|
||||
unlock := svc.locks.lock(gameID)
|
||||
defer unlock()
|
||||
@@ -1290,12 +1286,15 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
}
|
||||
}
|
||||
return StateView{
|
||||
Game: pre,
|
||||
Seat: seat,
|
||||
Rack: g.Hand(seat),
|
||||
BagLen: g.BagLen(),
|
||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||||
WalletBalance: acc.HintBalance,
|
||||
Game: pre,
|
||||
Seat: seat,
|
||||
Rack: g.Hand(seat),
|
||||
BagLen: g.BagLen(),
|
||||
// The hint wallet moved to payments (svc.hintWallet); the deprecated accounts.hint_balance
|
||||
// is no longer read, so the wire wallet is 0 and HintsRemaining is the per-seat allowance.
|
||||
// The client adds the profile's payments hint balance on top (lib/hints.hintsLeft).
|
||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, 0),
|
||||
WalletBalance: 0,
|
||||
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
|
||||
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
|
||||
}, nil
|
||||
|
||||
@@ -4,7 +4,6 @@ package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -280,76 +279,6 @@ func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleGrantHints drives the admin hint-wallet grant end to end: the card shows the form,
|
||||
// the action is CSRF-guarded, a same-origin grant adds to the wallet, a second grant adds again
|
||||
// (rather than replacing), the inclusive per-grant cap is accepted, and an out-of-range or
|
||||
// non-numeric amount is refused without changing the balance.
|
||||
func TestConsoleGrantHints(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts := account.NewStore(testDB)
|
||||
id := provisionAccount(t)
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zap.NewNop(), Accounts: accounts, Games: newGameService(), Registry: testRegistry, DictDir: dictDir(),
|
||||
})
|
||||
h := srv.Handler()
|
||||
base := "http://admin.test/_gm/users/" + id.String()
|
||||
|
||||
if code, body := consoleDo(h, http.MethodGet, base, "", ""); code != http.StatusOK || !strings.Contains(body, "Add hints") {
|
||||
t.Fatalf("user card = %d, has grant form = %v", code, strings.Contains(body, "Add hints"))
|
||||
}
|
||||
// The grant POST is CSRF-guarded like every console action.
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", ""); code != http.StatusForbidden {
|
||||
t.Fatalf("grant without origin = %d, want 403", code)
|
||||
}
|
||||
// A same-origin grant adds to the wallet.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 5") {
|
||||
t.Fatalf("grant 5 = %d, body has 'now 5' = %v", code, strings.Contains(body, "now 5"))
|
||||
}
|
||||
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 5 {
|
||||
t.Fatalf("after grant 5: balance=%d err=%v, want 5", acc.HintBalance, err)
|
||||
}
|
||||
// A second grant adds again rather than replacing.
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=3", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 8") {
|
||||
t.Fatalf("grant 3 = %d, body has 'now 8' = %v", code, strings.Contains(body, "now 8"))
|
||||
}
|
||||
// An out-of-range or non-numeric amount is refused; the balance is left untouched.
|
||||
for _, bad := range []string{"0", "-1", "101", "x", ""} {
|
||||
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount="+bad, "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Invalid amount") {
|
||||
t.Fatalf("grant %q = %d, has 'Invalid amount' = %v", bad, code, strings.Contains(body, "Invalid amount"))
|
||||
}
|
||||
}
|
||||
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 8 {
|
||||
t.Fatalf("after invalid grants: balance=%d err=%v, want 8", acc.HintBalance, err)
|
||||
}
|
||||
// The inclusive per-grant cap (100) is accepted.
|
||||
if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=100", "http://admin.test"); code != http.StatusOK {
|
||||
t.Fatalf("grant 100 = %d, want 200", code)
|
||||
}
|
||||
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 108 {
|
||||
t.Fatalf("after grant 100: balance=%d err=%v, want 108", acc.HintBalance, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantHintsStore covers the wallet store method directly: an additive grant raises the
|
||||
// balance, a non-positive grant is rejected, and an unknown account yields ErrNotFound.
|
||||
func TestGrantHintsStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accounts := account.NewStore(testDB)
|
||||
id := provisionAccount(t)
|
||||
if bal, err := accounts.GrantHints(ctx, id, 4); err != nil || bal != 4 {
|
||||
t.Fatalf("grant 4 = (%d, %v), want (4, nil)", bal, err)
|
||||
}
|
||||
if bal, err := accounts.GrantHints(ctx, id, 6); err != nil || bal != 10 {
|
||||
t.Fatalf("grant 6 = (%d, %v), want (10, nil)", bal, err)
|
||||
}
|
||||
if _, err := accounts.GrantHints(ctx, id, 0); err == nil {
|
||||
t.Error("grant 0 should be rejected (non-positive)")
|
||||
}
|
||||
if _, err := accounts.GrantHints(ctx, uuid.New(), 1); !errors.Is(err, account.ErrNotFound) {
|
||||
t.Errorf("grant unknown account = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// consoleDo issues a request to h, optionally with an Origin header, and returns
|
||||
// the status and body. Form bodies are sent as application/x-www-form-urlencoded.
|
||||
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
|
||||
|
||||
@@ -191,7 +191,7 @@ func TestBannerMessageOwnership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// profileBanner is the banner block of the profile.get JSON response.
|
||||
// profileBanner is the banner (and interstitial-ad) block of the profile.get JSON response.
|
||||
type profileBanner struct {
|
||||
Banner *struct {
|
||||
Campaigns []struct {
|
||||
@@ -203,6 +203,12 @@ type profileBanner struct {
|
||||
HoldMs int `json:"hold_ms"`
|
||||
} `json:"timings"`
|
||||
} `json:"banner"`
|
||||
Ads *struct {
|
||||
CooldownGlobalS int `json:"cooldown_global_s"`
|
||||
CooldownVsAiS int `json:"cooldown_vs_ai_s"`
|
||||
CooldownHintS int `json:"cooldown_hint_s"`
|
||||
Suppressed bool `json:"suppressed"`
|
||||
} `json:"ads"`
|
||||
}
|
||||
|
||||
// TestBannerProfileEligibility checks the profile.get banner block follows
|
||||
@@ -283,6 +289,81 @@ func TestBannerProfileEligibility(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileAdsConfig checks the profile.get interstitial-ad block: the seeded config cooldowns are
|
||||
// carried through, and Suppressed follows the same no-ads / no_banner gate as the banner (the client
|
||||
// self-gates VK-only + online on top). The no_banner role is context-independent; the no-ads benefit
|
||||
// applies only in a trusted context where its segment is present.
|
||||
func TestProfileAdsConfig(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
srv, _, pay := bannerServer(t)
|
||||
accounts := account.NewStore(testDB)
|
||||
id := provisionAccount(t)
|
||||
|
||||
get := func() profileBanner {
|
||||
t.Helper()
|
||||
rec := userGet(t, srv, "/api/v1/user/profile", id)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("profile = %d, want 200", rec.Code)
|
||||
}
|
||||
var p profileBanner
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
||||
t.Fatalf("decode profile: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
getTG := func() profileBanner {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
|
||||
req.Header.Set("X-User-ID", id.String())
|
||||
req.Header.Set("X-Platform", "telegram/android")
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("profile = %d, want 200", rec.Code)
|
||||
}
|
||||
var p profileBanner
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
||||
t.Fatalf("decode profile: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// A free account: the ads block carries the seeded config cooldowns and is not suppressed.
|
||||
p := get()
|
||||
if p.Ads == nil {
|
||||
t.Fatal("free account: no ads block")
|
||||
}
|
||||
if p.Ads.CooldownGlobalS != 300 || p.Ads.CooldownVsAiS != 1800 || p.Ads.CooldownHintS != 60 {
|
||||
t.Fatalf("cooldowns = %d/%d/%d, want 300/1800/60", p.Ads.CooldownGlobalS, p.Ads.CooldownVsAiS, p.Ads.CooldownHintS)
|
||||
}
|
||||
if p.Ads.Suppressed {
|
||||
t.Fatal("free account: interstitials must not be suppressed")
|
||||
}
|
||||
|
||||
// The no_banner role suppresses interstitials too (context-independent).
|
||||
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||
t.Fatalf("grant role: %v", err)
|
||||
}
|
||||
if p := get(); p.Ads == nil || !p.Ads.Suppressed {
|
||||
t.Fatalf("no_banner role: ads=%v, want suppressed", p.Ads)
|
||||
}
|
||||
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||
t.Fatalf("revoke role: %v", err)
|
||||
}
|
||||
|
||||
// An active no-ads benefit suppresses interstitials in a trusted context; an untrusted context
|
||||
// does not apply it (fail-closed to eligible, as for the banner).
|
||||
if err := pay.Grant(ctx, id, payments.SourceTelegram, 0, 30, false); err != nil {
|
||||
t.Fatalf("grant no-ads: %v", err)
|
||||
}
|
||||
if p := getTG(); p.Ads == nil || !p.Ads.Suppressed {
|
||||
t.Fatalf("no-ads benefit (trusted): ads=%v, want suppressed", p.Ads)
|
||||
}
|
||||
if p := get(); p.Ads == nil || p.Ads.Suppressed {
|
||||
t.Fatalf("no-ads benefit (untrusted): ads=%v, want NOT suppressed", p.Ads)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the
|
||||
// banner block too, so the client's profile keeps the banner instead of losing it until reload.
|
||||
func TestBannerSurvivesProfileUpdate(t *testing.T) {
|
||||
@@ -440,10 +521,4 @@ func TestBannerUrgentBypassesEligibility(t *testing.T) {
|
||||
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
|
||||
t.Fatalf("revoke role: %v", err)
|
||||
}
|
||||
|
||||
// A non-empty hint wallet no longer suppresses it either.
|
||||
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
|
||||
t.Fatalf("grant hints: %v", err)
|
||||
}
|
||||
assertUrgentOnly("hints", get())
|
||||
}
|
||||
|
||||
@@ -140,6 +140,12 @@ func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, am
|
||||
// them.
|
||||
const providerVKAds = "vk_ads"
|
||||
|
||||
// InterstitialCooldowns reports the post-move interstitial-ad cooldowns (seconds): global, vs_ai and
|
||||
// the independent hint-triggered one. The client mirrors them and self-gates (client-mirrored, D30).
|
||||
func (s *Service) InterstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) {
|
||||
return s.store.interstitialCooldowns(ctx)
|
||||
}
|
||||
|
||||
// RewardPayout reports the chips a rewarded-video view earns in the caller's context — the config
|
||||
// payout in a trusted VK context with the VK segment attached, and 0 everywhere else (rewarded is
|
||||
// VK-only, D28). The client uses it to gate the "watch for chips" button.
|
||||
|
||||
@@ -385,6 +385,20 @@ type RewardOutcome struct {
|
||||
AlreadyCredited bool
|
||||
}
|
||||
|
||||
// interstitialCooldowns reads the post-move interstitial-ad cooldowns (seconds): the global
|
||||
// per-user cooldown, the longer vs_ai one, and the independent hint-triggered one. The client mirrors
|
||||
// them and self-gates (E6/D30).
|
||||
func (s *Store) interstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) {
|
||||
var cfg model.Config
|
||||
if e := postgres.SELECT(table.Config.CooldownGlobalSeconds, table.Config.CooldownVsAiSeconds, table.Config.CooldownHintSeconds).
|
||||
FROM(table.Config).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &cfg); e != nil {
|
||||
return 0, 0, 0, fmt.Errorf("payments: read interstitial cooldowns: %w", e)
|
||||
}
|
||||
return int(cfg.CooldownGlobalSeconds), int(cfg.CooldownVsAiSeconds), int(cfg.CooldownHintSeconds), nil
|
||||
}
|
||||
|
||||
// rewardConfig reads the rewarded payout (chips per view) and the per-day and per-hour caps. The
|
||||
// caps are both anti-abuse (bounding a forger's free chips) and an economic conversion lever (free
|
||||
// rewarded chips are limited so a player who wants more buys) — tuned in the admin.
|
||||
|
||||
@@ -57,9 +57,9 @@ type bannerTimingsDTO struct {
|
||||
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
|
||||
r := profileResponseFor(acc)
|
||||
// Resolve the payments gate once (execution context + present sources) and feed it to both
|
||||
// the hint count and the banner. The profile hint balance now comes from the payments benefit
|
||||
// (context-aware), not the deprecated accounts.hint_balance column; on any failure the legacy
|
||||
// value from profileResponseFor (zeroed in production) stands.
|
||||
// the hint count and the banner. The profile hint balance comes from the payments benefit
|
||||
// (context-aware); the deprecated accounts.hint_balance column is no longer read, so on any
|
||||
// failure the fallback from profileResponseFor is a plain 0.
|
||||
cxt, present, err := s.walletGate(ctx, acc.ID)
|
||||
if err != nil {
|
||||
s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
@@ -71,6 +71,7 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
|
||||
}
|
||||
}
|
||||
r.Banner = s.bannerFor(ctx, acc, cxt, present)
|
||||
r.Ads = s.adsFor(ctx, acc, cxt, present)
|
||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||
r.DictVersions = s.currentDictVersions()
|
||||
return r
|
||||
@@ -177,6 +178,44 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account, cxt payment
|
||||
}
|
||||
}
|
||||
|
||||
// adsDTO is the post-move interstitial config in the profile: the client-mirrored cooldowns
|
||||
// (seconds) and whether ads are suppressed in the context (a no-ads benefit applicable here, or the
|
||||
// no_banner role). The client shows a VK interstitial after a confirmed move / hint only when not
|
||||
// suppressed and the mirrored cooldown has elapsed.
|
||||
type adsDTO struct {
|
||||
CooldownGlobalS int `json:"cooldown_global_s"`
|
||||
CooldownVsAiS int `json:"cooldown_vs_ai_s"`
|
||||
CooldownHintS int `json:"cooldown_hint_s"`
|
||||
Suppressed bool `json:"suppressed"`
|
||||
}
|
||||
|
||||
// adsFor builds the profile interstitial-ad config: the cooldowns and whether ads are suppressed
|
||||
// here (the same no-ads / no_banner gate as the banner). A read failure logs and yields a suppressed
|
||||
// block (fail-safe: no interstitial), so the profile still succeeds.
|
||||
func (s *Server) adsFor(ctx context.Context, acc account.Account, cxt payments.Context, present []payments.Source) *adsDTO {
|
||||
if s.payments == nil {
|
||||
return nil
|
||||
}
|
||||
global, vsAi, hint, err := s.payments.InterstitialCooldowns(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("profile: ad cooldowns read failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
return &adsDTO{Suppressed: true}
|
||||
}
|
||||
suppressed := false
|
||||
if adFree, aerr := s.payments.AdFree(ctx, acc.ID, cxt, present); aerr != nil {
|
||||
s.log.Warn("profile: ad-free read failed", zap.String("account", acc.ID.String()), zap.Error(aerr))
|
||||
suppressed = true // fail-safe: suppress the interstitial when eligibility is unknown
|
||||
} else {
|
||||
suppressed = adFree
|
||||
}
|
||||
if !suppressed {
|
||||
if noBanner, berr := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner); berr == nil {
|
||||
suppressed = noBanner
|
||||
}
|
||||
}
|
||||
return &adsDTO{CooldownGlobalS: global, CooldownVsAiS: vsAi, CooldownHintS: hint, Suppressed: suppressed}
|
||||
}
|
||||
|
||||
// bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
|
||||
// projecting each optional colour set into its three "#rrggbb" fields (empty when
|
||||
// the set is absent, so JSON omitempty drops them).
|
||||
|
||||
@@ -60,6 +60,11 @@ type profileResponse struct {
|
||||
// see the banner (a free account with an empty hint wallet and without the
|
||||
// no_banner role), absent otherwise. See banner.go.
|
||||
Banner *bannerDTO `json:"banner,omitempty"`
|
||||
// Ads carries the post-move interstitial config for the client's client-mirrored gate: the
|
||||
// cooldowns (seconds) and whether ads are suppressed in this context (no-ads / no_banner role).
|
||||
// The client shows a VK interstitial after a confirmed move / hint when not suppressed and the
|
||||
// cooldown has elapsed. Always present (the client also gates VK-only + online itself).
|
||||
Ads *adsDTO `json:"ads,omitempty"`
|
||||
// Email is the account's confirmed email address ("" when none); TelegramLinked and
|
||||
// VkLinked report whether a platform identity is attached. They drive the profile's
|
||||
// link / unlink / change-email controls, and are filled outside the pure projection
|
||||
@@ -218,13 +223,15 @@ func sessionResponseFor(token string, acc account.Account) sessionResponse {
|
||||
// profileResponseFor projects an account into its profile DTO.
|
||||
func profileResponseFor(acc account.Account) profileResponse {
|
||||
return profileResponse{
|
||||
UserID: acc.ID.String(),
|
||||
DisplayName: acc.DisplayName,
|
||||
PreferredLanguage: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone,
|
||||
AwayStart: acc.AwayStart.Format(awayTimeLayout),
|
||||
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
|
||||
HintBalance: acc.HintBalance,
|
||||
UserID: acc.ID.String(),
|
||||
DisplayName: acc.DisplayName,
|
||||
PreferredLanguage: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone,
|
||||
AwayStart: acc.AwayStart.Format(awayTimeLayout),
|
||||
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
|
||||
// The hint balance comes from the payments benefit; profileResponse overrides this
|
||||
// with the context-aware count. This zero is the fallback when that read fails.
|
||||
HintBalance: 0,
|
||||
BlockChat: acc.BlockChat,
|
||||
BlockFriendRequests: acc.BlockFriendRequests,
|
||||
IsGuest: acc.IsGuest,
|
||||
|
||||
@@ -29,10 +29,6 @@ import (
|
||||
// adminPageSize is the page size of the admin console's paginated lists.
|
||||
const adminPageSize = 50
|
||||
|
||||
// maxHintGrant caps a single operator hint grant. Grants are additive and can never lower a
|
||||
// wallet, so a fat-fingered grant cannot be undone through this form; the cap bounds one mistake.
|
||||
const maxHintGrant = 100
|
||||
|
||||
// registerConsole mounts the server-rendered admin console under /_gm. The gateway
|
||||
// puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the
|
||||
// backend trusts the gateway (as for all of /api) and adds only a same-origin guard
|
||||
@@ -56,7 +52,6 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.GET("/users/:id", s.consoleUserDetail)
|
||||
gm.POST("/users/:id/message", s.consoleUserMessage)
|
||||
gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag)
|
||||
gm.POST("/users/:id/grant-hints", s.consoleGrantHints)
|
||||
gm.POST("/users/:id/block", s.consoleBlockUser)
|
||||
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
|
||||
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
|
||||
@@ -354,7 +349,6 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
view := adminconsole.UserDetailView{
|
||||
ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
||||
PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, HintGrantMax: maxHintGrant,
|
||||
CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil,
|
||||
}
|
||||
if acc.MergedInto != uuid.Nil {
|
||||
@@ -963,30 +957,6 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
|
||||
s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String())
|
||||
}
|
||||
|
||||
// consoleGrantHints adds hints to a user's wallet. The grant is additive (raise-only): it tops a
|
||||
// player up and can never lower what they already hold, so blocking a reduction is inherent rather
|
||||
// than a separate guard. A single grant is bounded by maxHintGrant.
|
||||
func (s *Server) consoleGrantHints(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + id.String()
|
||||
n, err := strconv.Atoi(trimForm(c, "amount"))
|
||||
if err != nil || n < 1 || n > maxHintGrant {
|
||||
s.renderConsoleMessage(c, "Invalid amount", fmt.Sprintf("enter a whole number of hints to add, between 1 and %d", maxHintGrant), back)
|
||||
return
|
||||
}
|
||||
balance, err := s.accounts.GrantHints(c.Request.Context(), id, n)
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
// A non-empty hint wallet removes the banner: nudge an open client to re-check.
|
||||
s.publishBannerChange(id)
|
||||
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
|
||||
}
|
||||
|
||||
// consoleRemoveEmail deletes the account's bound email identity (and any pending
|
||||
// confirmations), freeing the address. It refuses to remove the account's only
|
||||
// identity, which would leave it unreachable.
|
||||
|
||||
Reference in New Issue
Block a user