Merge pull request 'feat(admin): выдача подсказок в кошелёк пользователя из админки' (#63) from feature/admin-grant-hints into development
This commit was merged in pull request #63.
This commit is contained in:
@@ -430,6 +430,30 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
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
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
<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}}
|
||||
|
||||
@@ -125,13 +125,16 @@ type UserDetailView struct {
|
||||
// empty for an unflagged account; the card shows it with the Clear action.
|
||||
FlaggedHighRateAt string
|
||||
HintBalance int
|
||||
CreatedAt string
|
||||
HasStats bool
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
Games []GameRow
|
||||
TelegramID string
|
||||
ConnectorEnabled bool
|
||||
// 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
|
||||
Games []GameRow
|
||||
TelegramID string
|
||||
ConnectorEnabled bool
|
||||
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||
// time (min/mean/max), empty when the account has no timed move.
|
||||
MoveChart template.HTML
|
||||
|
||||
@@ -4,6 +4,7 @@ package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -273,6 +274,76 @@ 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) {
|
||||
|
||||
@@ -28,6 +28,10 @@ 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
|
||||
@@ -51,6 +55,7 @@ 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.GET("/reasons", s.consoleReasons)
|
||||
@@ -263,8 +268,8 @@ 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, CreatedAt: fmtTime(acc.CreatedAt),
|
||||
HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil,
|
||||
PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, HintGrantMax: maxHintGrant,
|
||||
CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil,
|
||||
}
|
||||
if acc.MergedInto != uuid.Nil {
|
||||
view.MergedInto = acc.MergedInto.String()
|
||||
@@ -774,6 +779,28 @@ 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
|
||||
}
|
||||
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
|
||||
}
|
||||
|
||||
// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a
|
||||
// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active
|
||||
// games, removing them from matchmaking. The block takes effect on the player's next request.
|
||||
|
||||
@@ -477,7 +477,9 @@ English game the Latin pool.
|
||||
Migrations are embedded SQL applied with `pressly/goose/v3` at startup. Primary
|
||||
keys are application-generated **UUIDv7**.
|
||||
- Tables: `accounts` (durable internal accounts, carrying the away-window
|
||||
columns `away_start`/`away_end`, the hint wallet `hint_balance`, the `is_guest`
|
||||
columns `away_start`/`away_end`, the hint wallet `hint_balance` (spent after a
|
||||
game's per-seat allowance; an operator tops it up with an additive, raise-only
|
||||
grant from the admin console), the `is_guest`
|
||||
flag for ephemeral guest rows, the `notifications_in_app_only` out-of-app push
|
||||
toggle, the `paid_account` service flag and the merge-tombstone columns
|
||||
`merged_into`/`merged_at`),
|
||||
|
||||
@@ -220,3 +220,8 @@ is blocked"* (permanent) or *"…blocked until <date, time>"* (temporary,
|
||||
plus the reason when one was given, and the app stops all background traffic with the server. A
|
||||
temporary block lifts itself when it expires; the operator can also **unblock** from the user card
|
||||
at any time (games already lost stay lost).
|
||||
|
||||
From the user card the operator can also **top up a player's hint wallet**: an additive grant
|
||||
(1–100 hints per action) that raises the balance shown on the card. Grants are **raise-only** —
|
||||
the console can never lower a wallet (a player only loses hints by spending them in a game), so an
|
||||
over-grant cannot be reversed there.
|
||||
|
||||
@@ -226,3 +226,8 @@ high-rate флага. С карточки пользователя операт
|
||||
приложение прекращает любое фоновое общение с сервером. Временная блокировка снимается сама по
|
||||
истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент
|
||||
(уже проигранные партии не возвращаются).
|
||||
|
||||
С карточки пользователя оператор также может **пополнить кошелёк подсказок** игрока: аддитивное
|
||||
начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления
|
||||
**только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в
|
||||
партии), поэтому ошибочно начисленное через консоль там не отнять.
|
||||
|
||||
Reference in New Issue
Block a user