Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ce9f7e9b4 | |||
| 3456a0b3ff | |||
| a41281c495 | |||
| 516ffbe5f0 | |||
| 6badc20078 | |||
| 0ca01133b5 | |||
| 45f0b34881 | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -3,7 +3,7 @@
|
||||
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
|
||||
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
|
||||
#
|
||||
# Signing degrades gracefully: with the ANDROID_RUSTORE_* secrets present the APK is signed; without
|
||||
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
|
||||
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
|
||||
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook).
|
||||
# The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the runner host needs
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
- name: Decode the release keystore
|
||||
id: keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
|
||||
@@ -152,16 +152,16 @@ jobs:
|
||||
echo "keystore decoded -> signed release build"
|
||||
else
|
||||
echo "file=" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ANDROID_RUSTORE_KEYSTORE_BASE64 secret is not set — building an UNSIGNED release APK (not installable/publishable)"
|
||||
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
|
||||
fi
|
||||
|
||||
- name: Assemble the release APK
|
||||
working-directory: ui/android
|
||||
env:
|
||||
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RUSTORE_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEY_PASSWORD }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
|
||||
|
||||
- name: Upload the APK artifact
|
||||
|
||||
@@ -62,20 +62,19 @@ client-only (no server change).
|
||||
|
||||
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
|
||||
|
||||
> ### ▶ RESUME HERE — 2026-07-14 (keystore secrets set; payments landed; gate = RuStore account + merge/tag)
|
||||
> ### ▶ RESUME HERE — 2026-07-13 (documents track + release-prep)
|
||||
>
|
||||
> **Where we are.** The legal-documents track, the release-prep hygiene, the icon rebrand **and the
|
||||
> release keystore** are DONE (the keystore + its Gitea secrets set by the owner — see below). The
|
||||
> RuStore MVP is now gated on **one owner-side item** — the **RuStore developer account** (owner going
|
||||
> ИП; RuStore updated its requirements) — plus the agent's **promote/tag → signed-build** chain. Also
|
||||
> landed on `development` since: the payments **E10 multi-shop split** (#266) + **E11 kill switch**
|
||||
> (#267), both dormant / fail-open (no bearing on the Android build), and the signing-secret rename
|
||||
> `ANDROID_*` → **`ANDROID_RUSTORE_*`** (#268). Do not re-do the finished work or re-ask the locked
|
||||
> decisions.
|
||||
> **Where we are.** The legal-documents track, the release-prep hygiene, **and the icon rebrand** are
|
||||
> DONE. The RuStore release is now gated on **owner-side** items only (keystore, RuStore account) plus
|
||||
> the merge/tag chain. Do not re-do or re-litigate the finished work; do not re-ask the locked decisions.
|
||||
>
|
||||
> **Branches / PRs.** All merged to `development` (contour-green): legal documents (#253); release-prep
|
||||
> hygiene + the re-enabled `android-build` workflow (#254); the payments E10/E11 (#266/#267); the
|
||||
> signing-secret rename (#268). No open Android branch.
|
||||
> **Branches / PRs (current working branch: `feature/android-release-prep`).**
|
||||
> - `development` — has the **legal-documents track**, merged via **PR #253** (green, deployed to the
|
||||
> test contour). `/privacy/`, `/eula/`, `/offer/` are live there, bilingual RU/EN.
|
||||
> - `feature/android-release-prep` — the **release-prep hygiene**, **PR #254 OPEN, green, NOT merged**
|
||||
> (the owner chose to hold the merge). It is branched off `development`, so it also contains the
|
||||
> legal-documents track. Merge #254 into `development` when ready — needs **owner approval** (the
|
||||
> agent cannot self-approve).
|
||||
>
|
||||
> **Done this session.**
|
||||
> - **Legal documents** (satisfies the RuStore *hosted privacy-policy URL* prerequisite): authored
|
||||
@@ -114,12 +113,12 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
|
||||
> 2. **Merge PR #254** → `development` (owner approval), then **promote `development` → `master`** and
|
||||
> **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the
|
||||
> `X-Client-Version` header).
|
||||
> 3. **Release keystore — DONE (owner, 2026-07-14).** `erudit-release.jks` (RSA 4096, alias `erudit`,
|
||||
> PKCS12) generated + backed up off-host; the four Gitea secrets are set under the RuStore-specific
|
||||
> names `ANDROID_RUSTORE_KEYSTORE_BASE64` / `ANDROID_RUSTORE_KEYSTORE_PASSWORD` /
|
||||
> `ANDROID_RUSTORE_KEY_ALIAS` / `ANDROID_RUSTORE_KEY_PASSWORD` (loss ⇒ the app can never be updated —
|
||||
> the cert is pinned at the first RuStore upload). Google Play, later, would use its own
|
||||
> `ANDROID_PLAY_*` upload key (Play App Signing) — a separate key.
|
||||
> 3. **Release keystore.** OWNER generates it (`keytool -genkeypair -v -keystore erudit-release.jks
|
||||
> -alias erudit -keyalg RSA -keysize 4096 -validity 10000`), base64-encodes it, sets the four Gitea
|
||||
> **secrets** `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` /
|
||||
> `ANDROID_KEY_PASSWORD`, and backs the `.jks` up **off-host** (loss ⇒ the app can never be updated).
|
||||
> The AGENT provides the exact commands on request. Without the secrets the workflow builds an
|
||||
> UNSIGNED APK (a dry run still proves the pipeline).
|
||||
> 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates
|
||||
> publication, not the build.
|
||||
> 5. **Dispatch the signed build.** AGENT: on `master`, dispatch `android-build` (`confirm=build`) via
|
||||
@@ -133,16 +132,6 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
|
||||
> 8. **Upload to RuStore.** OWNER. After publication, set the `ANDROID_RUSTORE_URL` Gitea variable to
|
||||
> the store deep-link so the update button works on future gated releases (empty until then — the
|
||||
> gate is dormant in the MVP).
|
||||
>
|
||||
> **Next tracks (design in a future session — interview the owner first).**
|
||||
> - **Native notifications (push).** Promote FCM/push from "Out of scope" to a real track: a Capacitor
|
||||
> push plugin, the Android send path (FCM — but **check RuStore's own push service vs FCM on RuStore
|
||||
> devices**, since Google Play Services may be absent), token registration, and reusing the existing
|
||||
> server notification dispatcher. Owner will bring specifics.
|
||||
> - **A test APK for the emulator** (owner asked): the signed APK comes from `android-build` (needs the
|
||||
> promote/tag chain, step 2). A quick **debug** APK is buildable without the release chain if the host
|
||||
> has the Android SDK + JDK 21 (`pnpm build` → `cap sync` → `assembleDebug`).
|
||||
> - Possibly other native polish (owner to flag).
|
||||
|
||||
- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated
|
||||
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
|
||||
|
||||
@@ -39,13 +39,10 @@ status — without re-deriving decisions.
|
||||
| E7 | Admin, reports & catalog | 2 | DONE |
|
||||
| E8 | Guest limits | — | DONE |
|
||||
| E9 | Tournament fee | future | TODO |
|
||||
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | DONE |
|
||||
| E11 | Payment availability kill switch + per-account override | 2 | DONE |
|
||||
|
||||
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
||||
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
|
||||
parallel). E9 is future. E10 splits the `direct` rail into per-channel Robokassa shops + ИП
|
||||
fiscalization (post-launch, backend-first).
|
||||
parallel). E9 is future.
|
||||
|
||||
---
|
||||
|
||||
@@ -895,123 +892,6 @@ tournament-entry storage + pricing design.
|
||||
|
||||
---
|
||||
|
||||
## E10 — Multi-shop direct rail + ИП fiscalization
|
||||
|
||||
**Status:** DONE (merged — the multi-shop PR) · **Release 2+ (post-launch, backend-first)** · depends on: E5 (intake/`Fund`,
|
||||
the `robokassa` adapter, the Result callback), E7 (the per-user report — channel breakdown) ·
|
||||
mechanics: PAYMENTS §9, §12; decisions D41 (rev), D42–D44.
|
||||
|
||||
**Goal.** Split the single Robokassa `direct` rail into **per-channel merchant shops** (web,
|
||||
android; ios later) — separate merchant accounts / withdrawal / accounting and a per-channel
|
||||
breakdown in the E7 report — while keeping **one `direct` wallet** (D42). Land the wallet/identity
|
||||
rules the native apps need (email-only anchor, D43) and revise fiscalization for the owner's move
|
||||
to **ИП / 54-ФЗ** (D41 rev). All **additive / contour-safe** and **money-live** → expand-contract
|
||||
throughout.
|
||||
|
||||
**Locked decisions (owner interview 2026-07-14): D41 (rev), D42, D43, D44.** No wallet-model /
|
||||
spend-wall change; the split is merchant-account routing under one `direct` segment. Route the shop
|
||||
by the **trusted** `X-Platform` subtype, never a client field; unknown → `web`.
|
||||
|
||||
**B1 — Config: one shop → a set of shops.**
|
||||
|
||||
- `robokassa.Config` (single) → a **shops registry** keyed by channel (`web`, `android`; `ios`
|
||||
later), each 4 fields (`MerchantLogin`/`Password1`/`Password2`/`IsTest`). New env
|
||||
`BACKEND_ROBOKASSA_WEB_*`, `BACKEND_ROBOKASSA_ANDROID_*`.
|
||||
- **Expand-contract, no flag-day:** the legacy `BACKEND_ROBOKASSA_*` seeds the `web` shop; add the
|
||||
new vars; retire the legacy set after the prod rollout sets the split vars. `validate()`: a shop
|
||||
with a login must carry both passwords (mirror the current check), per shop.
|
||||
- Config/README + `deploy/.env.example` + the deploy ansible vars.
|
||||
|
||||
**B2 — Route the shop by channel (intake).**
|
||||
|
||||
- `handleWalletOrder` `SourceDirect` branch: pick the shop by `cxt.Subtype` (`web`/`android`;
|
||||
unknown → `web`). Build the payment request from that shop. The subtype rides the trusted
|
||||
gateway-injected `X-Platform` (`<kind>/<subtype>`, `parsePlatformHeader`) — no spoofable client
|
||||
field. **Verify the gateway emits `direct/android` for the native build**; if it sends a bare
|
||||
`direct/`, add the android subtype (small gateway change).
|
||||
- Record the chosen `shop` on the order (feeds B5).
|
||||
|
||||
**B3 — Per-shop Result callbacks.**
|
||||
|
||||
- The callback must select the right `Password2` → each shop gets its own Result URL:
|
||||
**per-shop public edge routes** (mirror the existing single Robokassa Result route, e.g.
|
||||
`…/result/web`, `…/result/android`), each forwarded to the backend and verified with that shop's
|
||||
config, then the same `Fund` (source stays `direct`). Add the routes to the **Caddyfile
|
||||
`@gateway` matcher** (else they fall to the landing catch-all) **+ a CI probe per route**.
|
||||
- Keep the legacy single route alive (shop = `web`) through the expand-contract window until the
|
||||
cabinet Result URLs are cut over.
|
||||
|
||||
**B4 — ИП fiscalization (D41 rev) — RESOLVED (owner 2026-07-14): cabinet-side only.** The owner keeps
|
||||
Robokassa's cabinet auto-fiscalization (a generic чек is acceptable for the ИП); the optional
|
||||
itemized-`Receipt` / `Email` code below is **dropped** — not needed. (Kept for context.)
|
||||
|
||||
- **Owner/cabinet:** enable the 54-ФЗ cloud kassa in the Robokassa ЛКК (kassa + ОФД + СНО).
|
||||
Required for ИП regardless of code.
|
||||
- **Code (optional, itemized чеки):** send a one-line `Receipt` (pack name, qty 1, `sum`, `tax`
|
||||
per СНО, `payment_object`/`payment_method`) + the customer `Email` (the D36 confirmed anchor) in
|
||||
the payment request; extend the signature to `MerchantLogin:OutSum:InvId:Receipt:Password1`
|
||||
(URL-encode `Receipt`). One line fits the current GET redirect; POST-form only as a URL-length
|
||||
fallback. One feature, all shops. **Sequenced last** — the split (B1–B3, B5, B6) needs no
|
||||
fiscalization. Supersedes the E5 §12 shop-side НПД receipt note.
|
||||
|
||||
**B5 — Channel in the report (D44).**
|
||||
|
||||
- Additive `shop` column on the order (default `web` / backfill from `origin`); a per-channel
|
||||
breakdown in the E7 per-user report (D40). Expand-contract migration (nullable/defaulted column),
|
||||
**contour-safe** (a schema touch → note the contour `DROP SCHEMA` step in `PRERELEASE.md`).
|
||||
|
||||
**B6 — Tests + docs.**
|
||||
|
||||
- unit: the `robokassa` shops registry + per-shop `VerifyResult` (right `Password2`);
|
||||
signature-with-`Receipt` (B4); intake shop routing by subtype (web/android/unknown→web).
|
||||
- integration: order→per-shop callback→credit (each route), a duplicate credits once, an expired
|
||||
order still honoured; the `shop` recorded + reported.
|
||||
- docs: `PAYMENTS.md` (+`_ru`) the multi-shop topology + the ИП/54-ФЗ receipt revision;
|
||||
`deploy/README` + `.env.example` the new vars + the per-shop Result routes + probes;
|
||||
`PRERELEASE.md` the `shop` column contour step.
|
||||
|
||||
**Done-criteria.** A `direct/web` order pays through the web shop and `direct/android` through the
|
||||
android shop, each verified with its own `Password2`, both crediting one `direct` wallet exactly
|
||||
once; the E7 report breaks payments down by channel; the split stays contour-safe (the legacy shop
|
||||
still works until the cabinet cutover). B4 (fiscalization) verified when the owner's ИП/ОФД/СНО are
|
||||
live.
|
||||
|
||||
**Notes/risks.** Edge routes fall through if not in the Caddyfile `@gateway` matcher — add a CI
|
||||
probe (field note). The prod rolling deploy skips caddy on config-only changes — force-recreate on a
|
||||
Caddyfile change. Money-live: expand-contract only, image rollback DB-safe. Confirm the gateway
|
||||
emits `direct/android` for the native build before relying on subtype routing.
|
||||
|
||||
---
|
||||
|
||||
## E11 — Payment availability kill switch + per-account override
|
||||
|
||||
**Status:** DONE · **Release 2** · depends on: E5 (intake/order), E7 (admin console) · mechanics:
|
||||
PAYMENTS §9; decisions D45, D46.
|
||||
|
||||
**Delivered.** An operator disables purchases live from `/_gm` — a whole rail/channel or one account
|
||||
— and the user sees a localized reason on the next purchase attempt. Motivation (owner): real apps
|
||||
show broken payments with no explanation; this gives ops a live switch + a clear user message.
|
||||
|
||||
- **Rail kill switch** — `payments.rail_status` (per rail `direct:web` / `direct:android` / `vk` /
|
||||
`telegram`): `enabled` + a per-language message, edited on the catalog page. **Fail-open** (no row
|
||||
⇒ enabled, so payments are never accidentally killed). The intake gate — `CanPurchase` in
|
||||
`handleWalletOrder`, before the order — returns `payment_unavailable` + the localized message;
|
||||
orthogonal to the security gates.
|
||||
- **Per-account override** — `payments.account_payment_override` (a row only for non-default; default
|
||||
= no row, cleared by delete): allow / deny / default, edited on the user card. `allow` bypasses
|
||||
**only** the ops rail switch, never the security gates (D46).
|
||||
- **Wire** — the message rides an additive `ExecuteResponse.message` (envelope layer,
|
||||
frozen-contract-safe; the gateway forwards a backend domain-error message via `DomainMessage`); the
|
||||
client reads it into `GatewayError.message` and shows it on a `payment_unavailable` buy attempt.
|
||||
- **Tests** — the pure gate `PurchaseGate` (unit, TDD); the store + gate + override end-to-end
|
||||
(integration, migration `00016`); the client (svelte-check / vitest). **Docs** — PAYMENTS (+`_ru`),
|
||||
decisions D45 / D46.
|
||||
|
||||
**Contour-safe:** additive migration (two new tables, no wipe); the wire add is additive; fail-open,
|
||||
so nothing is disabled until an operator acts.
|
||||
|
||||
---
|
||||
|
||||
## Verification & CI (all stages)
|
||||
|
||||
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 23 KiB |
@@ -19,11 +19,6 @@ const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64');
|
||||
const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
|
||||
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
|
||||
const MONO = uri(join(DIR, 'android-monochrome.png'));
|
||||
// The full-bleed master (square tile + master mark) — the legacy SQUARE launcher (API < 26)
|
||||
// uses it so old Android shows a large mark rather than the safe-zone foreground shrunk into
|
||||
// the middle. The round legacy icon keeps the safe-zone composite (a round mask would clip
|
||||
// the master's corner ✻).
|
||||
const MASTER = uri(join(ROOT, 'ui', 'assets', 'icon.png'));
|
||||
|
||||
// density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)]
|
||||
const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] };
|
||||
@@ -43,9 +38,8 @@ for (const [d, [fg, lg]] of Object.entries(D)) {
|
||||
writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false));
|
||||
writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true));
|
||||
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
|
||||
// Square launcher: the full-bleed master (large mark). Round launcher: the safe-zone
|
||||
// composite, circle-clipped (the master's corner ✻ would be clipped by the round mask).
|
||||
writeFileSync(join(dir, 'ic_launcher.png'), await shot(im(MASTER, lg), lg, false));
|
||||
const comp = `<div style="position:relative;width:${lg}px;height:${lg}px">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
||||
writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false));
|
||||
const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
||||
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
|
||||
console.log('mipmap-' + d, 'ok');
|
||||
|
||||
@@ -33,12 +33,8 @@ const SHADE = 0.15; // black, this alpha bottom-left -> transp
|
||||
// The mark, placed for the standalone master (bottom-right biased, full-bleed):
|
||||
const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] };
|
||||
// The mark, placed for the Android foreground layer (centred-ish inside the 61% safe
|
||||
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻).
|
||||
// Every value is the earlier placement scaled 0.75 about the icon centre (0.5, 0.5): the
|
||||
// whole mark is 25% smaller, its «Э»↔✻ arrangement unchanged, so the ✻ that used to graze
|
||||
// the round mask now sits well inside the safe zone. Round-mask launchers (adaptive + the
|
||||
// legacy round icon) get more breathing room; the full-bleed master is untouched.
|
||||
const FOREGROUND = { lh: 0.4043, lc: [0.5176, 0.4963], sd: 0.0989, sc: [0.6970, 0.6700] };
|
||||
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻):
|
||||
const FOREGROUND = { lh: 0.5391, lc: [0.5234, 0.4951], sd: 0.1318, sc: [0.7627, 0.7266] };
|
||||
|
||||
const PALETTE = {
|
||||
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 30 KiB |
@@ -49,14 +49,9 @@ COPY --from=build /out/backend /usr/local/bin/backend
|
||||
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
|
||||
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
|
||||
# can write new version subdirectories at runtime. The volume preserves uploaded
|
||||
# versions across deploys and, once seeded, is not re-seeded (ARCHITECTURE.md §5).
|
||||
# versions across deploys and, once seeded, is not re-seeded — so after bootstrap
|
||||
# every dictionary change goes through the console, not a rebuild (ARCHITECTURE.md §5).
|
||||
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg
|
||||
# A second, UNSHADOWED copy of the build's DAWGs: the /opt/dawg volume mount hides the
|
||||
# image's copy there, so this is where engine.DeliverVersion reads the build version from
|
||||
# to add it (add-only) to the volume on boot — the deploy-delivers path that lets a bumped
|
||||
# BACKEND_DICT_VERSION go live for new games without an admin upload (ARCHITECTURE.md §5).
|
||||
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg-seed
|
||||
ENV BACKEND_DICT_DIR=/opt/dawg
|
||||
ENV BACKEND_DICT_SEED_DIR=/opt/dawg-seed
|
||||
ENV BACKEND_DICT_VERSION=${DICT_VERSION}
|
||||
ENTRYPOINT ["/usr/local/bin/backend"]
|
||||
|
||||
@@ -111,12 +111,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
}
|
||||
logger.Info("database migrations applied")
|
||||
|
||||
// Deliver the build's dictionary version onto the persistent volume if a redeploy
|
||||
// bumped it (add-only; old versions in-flight games pin are untouched), so the new
|
||||
// dictionary is resident and can be activated below without an admin upload.
|
||||
if err := engine.DeliverVersion(cfg.Game.DictDir, cfg.Game.DictSeedDir, cfg.Game.DictVersion); err != nil {
|
||||
return fmt.Errorf("deliver dictionary version: %w", err)
|
||||
}
|
||||
registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load dictionaries: %w", err)
|
||||
@@ -124,7 +118,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
defer func() { _ = registry.Close() }()
|
||||
logger.Info("dictionaries loaded",
|
||||
zap.String("dir", cfg.Game.DictDir),
|
||||
zap.String("seed_dir", cfg.Game.DictSeedDir),
|
||||
zap.String("version", cfg.Game.DictVersion))
|
||||
|
||||
// Admin console: an optional backend client to the Telegram connector
|
||||
@@ -154,10 +147,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
accounts := account.NewStore(db)
|
||||
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
|
||||
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
||||
// Reconcile the active dictionary version with the registry: the build version
|
||||
// (delivered above) becomes active so a redeploy that bumped it goes live for new
|
||||
// games, except a newer version installed out-of-band through the admin console,
|
||||
// which is not downgraded by a restart (docs/ARCHITECTURE.md §5).
|
||||
// Reconcile the persisted active dictionary version with the registry: a
|
||||
// version activated through the admin console (and written to the dictionary
|
||||
// volume) is adopted again after a restart; otherwise the configured seed
|
||||
// version is kept and persisted (docs/ARCHITECTURE.md §5).
|
||||
if err := games.InitActiveVersion(ctx); err != nil {
|
||||
return fmt.Errorf("init active dictionary version: %w", err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -526,13 +525,8 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// guestDisplayName mints the display name stamped on a freshly provisioned guest: "Guest" plus a
|
||||
// random six-digit suffix (e.g. "Guest042317"), so a guest is distinguishable to opponents — in a
|
||||
// random match the other player sees a distinct name rather than every guest reading a bare
|
||||
// "Guest". Not an identifier and not unique (collisions are harmless — it is a label only).
|
||||
func guestDisplayName() string {
|
||||
return fmt.Sprintf("Guest%06d", rand.IntN(1_000_000))
|
||||
}
|
||||
// guestDisplayName is the display name stamped on a freshly provisioned guest.
|
||||
const guestDisplayName = "Guest"
|
||||
|
||||
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
|
||||
// no identity, flagged is_guest, so it can hold a session and a game seat (both
|
||||
@@ -540,7 +534,7 @@ func guestDisplayName() string {
|
||||
// and history. Guests are not reused — each bootstrap mints a new account. browserTZ
|
||||
// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling
|
||||
// back to the 'UTC' default when empty or malformed.
|
||||
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string) (Account, error) {
|
||||
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) {
|
||||
accountID, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("account: new guest id: %w", err)
|
||||
@@ -549,17 +543,9 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string)
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
// Seed the interface language from the client's detected locale (validated to a supported
|
||||
// one), so a fresh guest's language-dependent server content — the ad banner, bot messages —
|
||||
// is right from first contact rather than the 'en' column default until the client's later
|
||||
// language reconcile catches up. An unsupported or absent code keeps the 'en' default.
|
||||
lang := supportedLanguage(language)
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
stmt := table.Accounts.
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage).
|
||||
VALUES(accountID, guestDisplayName(), true, tz, lang).
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
|
||||
VALUES(accountID, guestDisplayName, true, tz).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
|
||||
@@ -21,29 +21,7 @@
|
||||
<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>Payment availability (kill switch)</h2>
|
||||
<p class="note">Turn purchases off on a rail/channel and show the user a localized reason on their next attempt. Unchecked = off; an empty message shows a built-in “temporarily unavailable”. Fail-open: a rail you never touch stays on. A per-user “allow” override (on a user card) bypasses this switch.</p>
|
||||
{{range .Rails}}
|
||||
<form class="form col" method="post" action="/_gm/catalog/rail-status">
|
||||
<input type="hidden" name="rail" value="{{.Rail}}">
|
||||
<label><input type="checkbox" name="enabled" {{if .Enabled}}checked{{end}}> <code>{{.Rail}}</code> — purchases enabled</label>
|
||||
<label>Message RU <input type="text" name="message_ru" maxlength="200" value="{{.MessageRU}}"></label>
|
||||
<label>Message EN <input type="text" name="message_en" maxlength="200" value="{{.MessageEN}}"></label>
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
{{end}}
|
||||
</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">
|
||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
|
||||
@@ -72,24 +72,14 @@
|
||||
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
|
||||
</ul>
|
||||
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
||||
<h3>Payment override</h3>
|
||||
<form class="form" method="post" action="/_gm/users/{{.ID}}/purchase-override">
|
||||
<label>Purchases for this account
|
||||
<select name="override">
|
||||
<option value="default" {{if eq .PurchaseOverride "default"}}selected{{end}}>Default (follow the rail switch)</option>
|
||||
<option value="allow" {{if eq .PurchaseOverride "allow"}}selected{{end}}>Always allow (bypasses the rail switch only, not security)</option>
|
||||
<option value="deny" {{if eq .PurchaseOverride "deny"}}selected{{end}}>Always deny</option>
|
||||
</select></label>
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
<h3>Ledger</h3>
|
||||
{{$uid := .ID}}
|
||||
{{if .Finance.Ledger}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Shop</th><th>Detail</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Finance.Ledger}}
|
||||
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{.Shop}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
|
||||
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
|
||||
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -198,9 +198,6 @@ type UserDetailView struct {
|
||||
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
||||
// is false when the payments domain is unwired.
|
||||
Finance FinanceView
|
||||
// PurchaseOverride is the account's per-account purchase override ("default"/"allow"/"deny"),
|
||||
// shown in and edited from the user card's payment-override control.
|
||||
PurchaseOverride string
|
||||
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
|
||||
// payments domain is unwired.
|
||||
Grant GrantFormView
|
||||
@@ -235,8 +232,8 @@ type BenefitRow struct {
|
||||
}
|
||||
|
||||
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
|
||||
// delta, the product / order / provider / direct-rail shop it references (empty when none), the raw
|
||||
// snapshot JSON and the pre-formatted time.
|
||||
// delta, the product / order / provider it references (empty when none), the raw snapshot JSON and
|
||||
// the pre-formatted time.
|
||||
type LedgerRow struct {
|
||||
Kind string
|
||||
Source string
|
||||
@@ -245,7 +242,6 @@ type LedgerRow struct {
|
||||
Product string
|
||||
Order string
|
||||
Provider string
|
||||
Shop string
|
||||
Snapshot string
|
||||
At string
|
||||
}
|
||||
@@ -681,27 +677,6 @@ type FeedbackDetailView struct {
|
||||
// CatalogView is the product-catalog list page.
|
||||
type CatalogView struct {
|
||||
Products []ProductRow
|
||||
// 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
|
||||
// Rails is the per-rail operational kill-switch state (enabled + per-language off-message), shown
|
||||
// in and edited from the page's payment-availability form.
|
||||
Rails []RailStatusRow
|
||||
}
|
||||
|
||||
// RailStatusRow is one payment rail's operational availability in the kill-switch editor: the rail
|
||||
// key, whether purchases are enabled, and the operator's per-language off-message shown to the user.
|
||||
type RailStatusRow struct {
|
||||
Rail string
|
||||
Enabled bool
|
||||
MessageRU string
|
||||
MessageEN string
|
||||
}
|
||||
|
||||
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
||||
|
||||
@@ -65,9 +65,9 @@ type Config struct {
|
||||
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||
RendererURL string
|
||||
// Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
|
||||
// (D42). An empty set leaves the direct order and Result-callback endpoints unregistered.
|
||||
Robokassa robokassa.Shops
|
||||
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
|
||||
// leaves the direct order and Result-callback endpoints unregistered.
|
||||
Robokassa robokassa.Config
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
@@ -106,7 +106,6 @@ func Load() (Config, error) {
|
||||
gm := game.DefaultConfig()
|
||||
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
|
||||
gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
|
||||
gm.DictSeedDir = envOr("BACKEND_DICT_SEED_DIR", gm.DictSeedDir)
|
||||
if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
@@ -158,19 +157,11 @@ func Load() (Config, error) {
|
||||
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
||||
}
|
||||
|
||||
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
|
||||
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
|
||||
// with no MerchantLogin is dropped (the rail stays dormant when none is configured).
|
||||
shops := robokassa.Shops{}
|
||||
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
|
||||
if web.MerchantLogin == "" {
|
||||
web = robokassaShop("BACKEND_ROBOKASSA") // legacy single-shop credentials seed the web channel
|
||||
}
|
||||
if web.MerchantLogin != "" {
|
||||
shops[robokassa.ChannelWeb] = web
|
||||
}
|
||||
if android := robokassaShop("BACKEND_ROBOKASSA_ANDROID"); android.MerchantLogin != "" {
|
||||
shops[robokassa.ChannelAndroid] = android
|
||||
robo := robokassa.Config{
|
||||
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
|
||||
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
|
||||
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
|
||||
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
|
||||
}
|
||||
|
||||
c := Config{
|
||||
@@ -190,7 +181,7 @@ func Load() (Config, error) {
|
||||
GuestRetention: guestRetention,
|
||||
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||
Robokassa: shops,
|
||||
Robokassa: robo,
|
||||
}
|
||||
if err := c.validate(); err != nil {
|
||||
return Config{}, err
|
||||
@@ -243,10 +234,8 @@ func (c Config) validate() error {
|
||||
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
||||
}
|
||||
}
|
||||
for channel, shop := range c.Robokassa {
|
||||
if shop.Password1 == "" || shop.Password2 == "" {
|
||||
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
|
||||
}
|
||||
if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") {
|
||||
return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -287,15 +276,3 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
|
||||
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
|
||||
// MerchantLogin yields a zero Config the caller drops.
|
||||
func robokassaShop(prefix string) robokassa.Config {
|
||||
return robokassa.Config{
|
||||
MerchantLogin: os.Getenv(prefix + "_MERCHANT_LOGIN"),
|
||||
Password1: os.Getenv(prefix + "_PASSWORD1"),
|
||||
Password2: os.Getenv(prefix + "_PASSWORD2"),
|
||||
IsTest: os.Getenv(prefix+"_TEST") == "1",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DeliverVersion makes the build's dictionary version resident on the persistent
|
||||
// dictionary volume without disturbing the versions in-flight games already pin.
|
||||
//
|
||||
// The volume is seeded from the image once and never re-seeded, and the image's
|
||||
// /opt/dawg is shadowed by that volume mount, so a redeploy that bumped
|
||||
// BACKEND_DICT_VERSION cannot otherwise make the new DAWGs reachable at runtime.
|
||||
// The image therefore keeps an unshadowed read-only copy at seedDir, and this
|
||||
// copies it into dictDir/<version>/ when the version is not already present —
|
||||
// neither the flat seed's own recorded label nor an existing version
|
||||
// subdirectory. It is add-only and idempotent: the flat seed and any prior admin
|
||||
// uploads are never touched, so old games keep the version they pin while the new
|
||||
// one becomes resident and activatable (see game.Service.InitActiveVersion). A
|
||||
// blank seedDir disables delivery (the pre-existing seed-only behaviour).
|
||||
func DeliverVersion(dictDir, seedDir, version string) error {
|
||||
if seedDir == "" || version == "" {
|
||||
return nil
|
||||
}
|
||||
target := filepath.Join(dictDir, version)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return nil // already delivered on a prior boot
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("engine: stat delivered dictionary %s: %w", target, err)
|
||||
}
|
||||
// A fresh volume is populated from the image and recorded as this version by the
|
||||
// marker: it is the flat dir, so there is nothing to deliver. resolveSeedVersion
|
||||
// records the label on first use, matching OpenWithVersions.
|
||||
seed, err := resolveSeedVersion(dictDir, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if seed == version {
|
||||
return nil
|
||||
}
|
||||
// Stage into a dot-prefixed directory (skipped by OpenWithVersions' version scan)
|
||||
// then rename, so a crash mid-copy never leaves a half-populated version subdir.
|
||||
staging := filepath.Join(dictDir, ".staging-deliver-"+version)
|
||||
if err := os.RemoveAll(staging); err != nil {
|
||||
return fmt.Errorf("engine: clear deliver staging %s: %w", staging, err)
|
||||
}
|
||||
if err := os.MkdirAll(staging, 0o755); err != nil {
|
||||
return fmt.Errorf("engine: create deliver staging %s: %w", staging, err)
|
||||
}
|
||||
for _, file := range dictFiles {
|
||||
src := filepath.Join(seedDir, file)
|
||||
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
|
||||
continue // a variant absent from the seed is simply absent under this version
|
||||
} else if err != nil {
|
||||
_ = os.RemoveAll(staging)
|
||||
return fmt.Errorf("engine: stat seed dawg %s: %w", src, err)
|
||||
}
|
||||
if err := copyFile(src, filepath.Join(staging, file)); err != nil {
|
||||
_ = os.RemoveAll(staging)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.Rename(staging, target); err != nil {
|
||||
_ = os.RemoveAll(staging)
|
||||
return fmt.Errorf("engine: publish delivered dictionary %s: %w", target, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile copies a single file, truncating the destination.
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine: open %s: %w", src, err)
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine: create %s: %w", dst, err)
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return fmt.Errorf("engine: copy %s -> %s: %w", src, dst, err)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return fmt.Errorf("engine: finalise %s: %w", dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeliverVersion(t *testing.T) {
|
||||
// A blank seed directory disables delivery (the pre-existing seed-only behaviour).
|
||||
if err := DeliverVersion(t.TempDir(), "", "v1.3.1"); err != nil {
|
||||
t.Fatalf("blank seedDir: %v", err)
|
||||
}
|
||||
|
||||
// An existing volume flat-seeded as v1.3.0 gets v1.3.1 delivered as a subdirectory,
|
||||
// add-only: the flat seed's marker is left intact and the new version's DAWGs land.
|
||||
dict := t.TempDir()
|
||||
seed := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dict, seedMarkerFile), []byte("v1.3.0\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range dictFiles {
|
||||
if err := os.WriteFile(filepath.Join(seed, f), []byte("dawg:"+f), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
|
||||
t.Fatalf("deliver v1.3.1: %v", err)
|
||||
}
|
||||
for _, f := range dictFiles {
|
||||
got, err := os.ReadFile(filepath.Join(dict, "v1.3.1", f))
|
||||
if err != nil {
|
||||
t.Fatalf("delivered %s: %v", f, err)
|
||||
}
|
||||
if string(got) != "dawg:"+f {
|
||||
t.Errorf("delivered %s = %q, want the seed bytes", f, got)
|
||||
}
|
||||
}
|
||||
if m, _ := os.ReadFile(filepath.Join(dict, seedMarkerFile)); string(m) != "v1.3.0\n" {
|
||||
t.Errorf("flat seed marker relabelled to %q (delivery must be add-only)", m)
|
||||
}
|
||||
// Idempotent: a second call is a no-op and leaves no staging directory behind.
|
||||
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
|
||||
t.Fatalf("re-deliver v1.3.1: %v", err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(dict); hasStaging(entries) {
|
||||
t.Errorf("a staging directory survived delivery")
|
||||
}
|
||||
|
||||
// On a fresh directory the build version becomes the flat seed, so nothing is delivered
|
||||
// as a redundant subdirectory.
|
||||
fresh := t.TempDir()
|
||||
if err := DeliverVersion(fresh, seed, "v1.3.1"); err != nil {
|
||||
t.Fatalf("fresh deliver: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(fresh, "v1.3.1")); !os.IsNotExist(err) {
|
||||
t.Errorf("fresh volume got a redundant v1.3.1 subdir; it should be the flat seed")
|
||||
}
|
||||
}
|
||||
|
||||
func hasStaging(entries []os.DirEntry) bool {
|
||||
for _, e := range entries {
|
||||
if len(e.Name()) >= len(".staging") && e.Name()[:len(".staging")] == ".staging" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -25,11 +25,11 @@ const seedMarkerFile = ".seed_version"
|
||||
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
|
||||
// - already-seeded directory: return the recorded marker and ignore bootVersion.
|
||||
//
|
||||
// So a later build seed never relabels the already-seeded flat bytes — which would void
|
||||
// games pinned to the prior label and mis-serve new ones. The new build version is instead
|
||||
// delivered as a NEW subdirectory (DeliverVersion) and made active (Service.InitActiveVersion),
|
||||
// so a redeploy still moves new games to it while old games keep the flat label. New games
|
||||
// pin the active version (DB-persisted); a console upload can also set it out-of-band.
|
||||
// So bumping the build seed on a live volume is a harmless no-op (it only takes
|
||||
// effect on a future fresh volume) instead of relabelling the already-seeded bytes —
|
||||
// which would void games pinned to the prior label and mis-serve new ones. New games
|
||||
// still pin the active version (DB-persisted, set by the admin console), which is the
|
||||
// real way a running contour moves to a new release.
|
||||
//
|
||||
// A directory that cannot be written makes the first record fail; that also breaks
|
||||
// the admin console (which writes version subdirectories here), so the error is
|
||||
|
||||
@@ -18,13 +18,6 @@ type Config struct {
|
||||
// DictVersion labels the dictionary version new games pin. Sourced from
|
||||
// BACKEND_DICT_VERSION.
|
||||
DictVersion string
|
||||
// DictSeedDir holds the image's read-only copy of the build's DictVersion DAWGs,
|
||||
// on a path the persistent DictDir volume does NOT shadow. On boot the backend
|
||||
// delivers this version into DictDir/<DictVersion>/ if absent (add-only), so a
|
||||
// redeploy that bumped DICT_VERSION makes the new dictionary resident and activatable
|
||||
// without disturbing the versions in-flight games already pin. Sourced from
|
||||
// BACKEND_DICT_SEED_DIR; empty disables delivery (the pre-existing seed-only behaviour).
|
||||
DictSeedDir string
|
||||
// TimeoutSweepInterval is how often the sweeper scans for overdue turns.
|
||||
// Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL.
|
||||
TimeoutSweepInterval time.Duration
|
||||
|
||||
@@ -232,31 +232,21 @@ func (svc *Service) ActiveVersion() string {
|
||||
}
|
||||
|
||||
// InitActiveVersion reconciles the persisted active dictionary version with the
|
||||
// registry at startup and makes the build's version (BACKEND_DICT_VERSION, delivered
|
||||
// resident by engine.DeliverVersion) authoritative: a redeploy that bumped the build
|
||||
// version thus activates it for new games without any admin step, while old games keep
|
||||
// the version they pin. The one exception is a version installed out-of-band through
|
||||
// the admin console that is NEWER than the build version and still resident — it is
|
||||
// kept, so a restart on an older image never downgrades a hotfix. A build version that
|
||||
// is not resident (delivery disabled or a partial state) falls back to a resident
|
||||
// persisted version. The chosen version is persisted so the admin console reflects it.
|
||||
// Call once during wiring, before serving traffic.
|
||||
// registry at startup. If a version was persisted and is resident, it becomes the
|
||||
// active version; otherwise the configured seed version is kept and persisted as
|
||||
// the initial active version. Call once during wiring, before serving traffic.
|
||||
func (svc *Service) InitActiveVersion(ctx context.Context) error {
|
||||
persisted, ok, err := svc.store.GetActiveDictVersion(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := svc.activeVersion() // the build seed version (config.DictVersion)
|
||||
if ok && svc.versionResident(persisted) && compareDictVersions(persisted, target) > 0 {
|
||||
target = persisted // a newer out-of-band install wins over the build version
|
||||
}
|
||||
if !svc.versionResident(target) && ok && svc.versionResident(persisted) {
|
||||
target = persisted // the build version is unloadable — keep a resident one
|
||||
}
|
||||
if ok && svc.versionResident(persisted) {
|
||||
svc.verMu.Lock()
|
||||
svc.version = target
|
||||
svc.version = persisted
|
||||
svc.verMu.Unlock()
|
||||
return svc.store.SetActiveDictVersion(ctx, target)
|
||||
return nil
|
||||
}
|
||||
return svc.store.SetActiveDictVersion(ctx, svc.activeVersion())
|
||||
}
|
||||
|
||||
// SetActiveVersion records version as the active dictionary version, persisting it
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// compareDictVersions orders two dictionary version labels of the shape "vMAJOR.MINOR.PATCH"
|
||||
// numerically, returning -1, 0 or +1 as a is less than, equal to or greater than b. Labels
|
||||
// that do not parse fall back to a byte comparison, so the ordering is always total (a mixed
|
||||
// or malformed pair never crashes the boot-time active-version reconcile).
|
||||
func compareDictVersions(a, b string) int {
|
||||
pa, oka := parseDictVersion(a)
|
||||
pb, okb := parseDictVersion(b)
|
||||
if !oka || !okb {
|
||||
return strings.Compare(a, b)
|
||||
}
|
||||
for i := range pa {
|
||||
if pa[i] != pb[i] {
|
||||
if pa[i] < pb[i] {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseDictVersion parses a "vMAJOR.MINOR.PATCH" label into its three numeric parts, reporting
|
||||
// whether it matched that shape.
|
||||
func parseDictVersion(v string) ([3]int, bool) {
|
||||
parts := strings.Split(strings.TrimPrefix(v, "v"), ".")
|
||||
if len(parts) != 3 {
|
||||
return [3]int{}, false
|
||||
}
|
||||
var out [3]int
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return [3]int{}, false
|
||||
}
|
||||
out[i] = n
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package game
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCompareDictVersions(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"v1.3.0", "v1.3.1", -1},
|
||||
{"v1.3.1", "v1.3.0", 1},
|
||||
{"v1.3.1", "v1.3.1", 0},
|
||||
{"v1.3.9", "v1.3.10", -1}, // numeric, not lexical
|
||||
{"v1.10.0", "v1.9.0", 1},
|
||||
{"v2.0.0", "v1.9.9", 1},
|
||||
{"1.3.1", "v1.3.1", 0}, // the leading v is optional
|
||||
// Non-semver falls back to a byte comparison, keeping the ordering total.
|
||||
{"weird", "weird", 0},
|
||||
{"a", "b", -1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := compareDictVersions(c.a, c.b); sign(got) != c.want {
|
||||
t.Errorf("compareDictVersions(%q, %q) = %d, want sign %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sign(n int) int {
|
||||
switch {
|
||||
case n < 0:
|
||||
return -1
|
||||
case n > 0:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package inttest
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -230,40 +229,21 @@ func TestProvisionSeedsTimeZone(t *testing.T) {
|
||||
t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone)
|
||||
}
|
||||
|
||||
// A guest is seeded its detected offset and interface language; an empty offset keeps the
|
||||
// UTC default and an empty/unsupported language keeps the 'en' default.
|
||||
guest, err := store.ProvisionGuest(ctx, "-05:30", "ru")
|
||||
// A guest is seeded its detected offset; an empty one keeps the UTC default.
|
||||
guest, err := store.ProvisionGuest(ctx, "-05:30")
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
if guest.TimeZone != "-05:30" {
|
||||
t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone)
|
||||
}
|
||||
if guest.PreferredLanguage != "ru" {
|
||||
t.Errorf("guest PreferredLanguage = %q, want the seeded ru", guest.PreferredLanguage)
|
||||
}
|
||||
// A guest's display name is "Guest" + a random six-digit suffix (distinct to opponents).
|
||||
if m, _ := regexp.MatchString(`^Guest\d{6}$`, guest.DisplayName); !m {
|
||||
t.Errorf("guest DisplayName = %q, want Guest + 6 digits", guest.DisplayName)
|
||||
}
|
||||
plainGuest, err := store.ProvisionGuest(ctx, "", "")
|
||||
plainGuest, err := store.ProvisionGuest(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision plain guest: %v", err)
|
||||
}
|
||||
if plainGuest.TimeZone != "UTC" {
|
||||
t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone)
|
||||
}
|
||||
if plainGuest.PreferredLanguage != "en" {
|
||||
t.Errorf("plain guest PreferredLanguage = %q, want en default", plainGuest.PreferredLanguage)
|
||||
}
|
||||
// An unsupported locale falls back to the 'en' default rather than persisting a junk value.
|
||||
frGuest, err := store.ProvisionGuest(ctx, "", "fr-FR")
|
||||
if err != nil {
|
||||
t.Fatalf("provision fr guest: %v", err)
|
||||
}
|
||||
if frGuest.PreferredLanguage != "en" {
|
||||
t.Errorf("unsupported-locale guest PreferredLanguage = %q, want en default", frGuest.PreferredLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// productByTitle finds a catalog product by its (unique in the test) title.
|
||||
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
||||
t.Helper()
|
||||
all, err := pay.AdminCatalog(context.Background(), true)
|
||||
all, err := pay.AdminCatalog(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("admin catalog: %v", err)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
||||
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
||||
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
||||
}
|
||||
if _, err := pay.AdminCatalog(ctx, true); err != nil {
|
||||
if _, err := pay.AdminCatalog(ctx); err != nil {
|
||||
t.Fatalf("catalog: %v", err)
|
||||
}
|
||||
|
||||
@@ -95,59 +95,3 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
||||
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and
|
||||
// includes the archived ones under ?all=1 (the active/all toggle).
|
||||
func TestConsoleCatalogActiveToggle(t *testing.T) {
|
||||
srv, _, _ := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
const origin = "http://admin.test"
|
||||
const catalog = "http://admin.test/_gm/catalog"
|
||||
|
||||
// An active value and an archived one (created without the active flag).
|
||||
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK {
|
||||
t.Fatal("create active failed")
|
||||
}
|
||||
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK {
|
||||
t.Fatal("create archived failed")
|
||||
}
|
||||
|
||||
// Default view: active only.
|
||||
if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") {
|
||||
t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)",
|
||||
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||
}
|
||||
|
||||
// ?all=1: active and archived.
|
||||
if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") {
|
||||
t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)",
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ func TestConfirmCodeClearsGuest(t *testing.T) {
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||
|
||||
guest, err := store.ProvisionGuest(ctx, "", "")
|
||||
guest, err := store.ProvisionGuest(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
|
||||
@@ -103,31 +103,14 @@ func TestDictionaryUpdateFlow(t *testing.T) {
|
||||
t.Errorf("installed dawg missing on disk: %v", err)
|
||||
}
|
||||
|
||||
// The choice survives a restart on the SAME build: an out-of-band admin install newer
|
||||
// than the build version is not downgraded (a restart on an older image keeps the hotfix).
|
||||
// The choice survives a restart: a fresh service over the same DB and registry
|
||||
// re-adopts the persisted active version.
|
||||
restarted := newGameServiceOn(dir, "v1.0.0", reg)
|
||||
if err := restarted.InitActiveVersion(ctx); err != nil {
|
||||
t.Fatalf("restart init: %v", err)
|
||||
}
|
||||
if got := restarted.ActiveVersion(); got != "v9.9.9" {
|
||||
t.Errorf("restarted active version = %q, want v9.9.9 (a newer out-of-band install is kept)", got)
|
||||
}
|
||||
|
||||
// A redeploy that delivered a build version NEWER than the out-of-band one activates it
|
||||
// for new games without any admin step (the deploy-delivers path — docs/ARCHITECTURE.md §5).
|
||||
if _, err := reg.LoadAvailable(dir, "v10.0.0"); err != nil {
|
||||
t.Fatalf("make v10.0.0 resident: %v", err)
|
||||
}
|
||||
deployed := newGameServiceOn(dir, "v10.0.0", reg)
|
||||
if err := deployed.InitActiveVersion(ctx); err != nil {
|
||||
t.Fatalf("deploy init: %v", err)
|
||||
}
|
||||
if got := deployed.ActiveVersion(); got != "v10.0.0" {
|
||||
t.Errorf("deployed active version = %q, want v10.0.0 (a newer build version wins)", got)
|
||||
}
|
||||
// Restore the out-of-band active version so the assertions below still read v9.9.9.
|
||||
if err := restarted.SetActiveVersion(ctx, "v9.9.9"); err != nil {
|
||||
t.Fatalf("restore active: %v", err)
|
||||
t.Errorf("restarted active version = %q, want v9.9.9 (persisted)", got)
|
||||
}
|
||||
|
||||
// New games pin the new version; the in-progress game keeps its own, still resident.
|
||||
|
||||
@@ -431,7 +431,7 @@ func TestConfirmByTokenLinkClearsGuest(t *testing.T) {
|
||||
store := account.NewStore(testDB)
|
||||
mailer := &capturingMailer{}
|
||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||
guest, err := store.ProvisionGuest(ctx, "", "")
|
||||
guest, err := store.ProvisionGuest(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ func provisionAccount(t *testing.T) uuid.UUID {
|
||||
// provisionGuest creates a fresh ephemeral guest account and returns its id.
|
||||
func provisionGuest(t *testing.T) uuid.UUID {
|
||||
t.Helper()
|
||||
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "", "")
|
||||
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
|
||||
@@ -347,10 +347,8 @@ func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkGuestInversion auto-merges a guest initiator into the durable account that
|
||||
// owns the email AT THE CONFIRM STEP (no merge confirmation): the guest is retired, the durable
|
||||
// account wins and a fresh session is minted for it (the client adopts the switch and lands on
|
||||
// the durable account as if it simply logged in).
|
||||
// TestAccountLinkGuestInversion merges a guest initiator into the durable account
|
||||
// that owns the email: the durable account wins and a fresh session is minted.
|
||||
func TestAccountLinkGuestInversion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
@@ -366,18 +364,17 @@ func TestAccountLinkGuestInversion(t *testing.T) {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
confirm, err := links.ConfirmEmail(ctx, guest, email, code)
|
||||
if err != nil {
|
||||
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
|
||||
t.Fatalf("confirm: %v", err)
|
||||
}
|
||||
// A guest initiator does not get a MergeRequired confirmation — the merge runs inline.
|
||||
if !confirm.Merged || confirm.MergeRequired {
|
||||
t.Fatalf("confirm = %+v, want an inline (auto) merge", confirm)
|
||||
merge, err := links.MergeEmail(ctx, guest, email, code)
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
}
|
||||
if confirm.Merge.PrimaryID != durable {
|
||||
t.Fatalf("primary = %s, want durable %s", confirm.Merge.PrimaryID, durable)
|
||||
if merge.PrimaryID != durable {
|
||||
t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
|
||||
}
|
||||
if confirm.Merge.SwitchedToken == "" {
|
||||
if merge.SwitchedToken == "" {
|
||||
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
|
||||
}
|
||||
if mergedInto(t, guest) != durable {
|
||||
@@ -388,33 +385,6 @@ func TestAccountLinkGuestInversion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkGuestAutoMergeActiveGameConflict guards that a guest's auto-merge is REFUSED
|
||||
// (not silently swallowed) at the confirm step when the guest and the durable account share an
|
||||
// active game: the caller surfaces ErrActiveGameConflict (mapped to a clear "finish that game
|
||||
// first" message) rather than seating one player against themselves.
|
||||
func TestAccountLinkGuestAutoMergeActiveGameConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mailer := &capturingMailer{}
|
||||
links := newLinkService(mailer)
|
||||
|
||||
durable := provisionAccount(t)
|
||||
email := "conflict-" + uuid.NewString() + "@example.com"
|
||||
bindEmailIdentity(t, durable, email)
|
||||
guest := provisionGuest(t)
|
||||
seatGame(t, []uuid.UUID{durable, guest}, 24*time.Hour) // an active game the two share
|
||||
|
||||
if err := links.RequestEmail(ctx, guest, email); err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
code := sixDigit.FindString(mailer.lastBody)
|
||||
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != accountmerge.ErrActiveGameConflict {
|
||||
t.Fatalf("confirm = %v, want ErrActiveGameConflict surfaced by the auto-merge", err)
|
||||
}
|
||||
if mergedInto(t, guest) != uuid.Nil {
|
||||
t.Error("a refused auto-merge must not tombstone the guest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
|
||||
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
|
||||
func TestAccountLinkFreeVK(t *testing.T) {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
|
||||
// TestPaymentAvailabilityKillSwitch exercises the per-rail kill switch + the per-account override
|
||||
// end-to-end against Postgres: fail-open, a disabled rail with a localized message, per-rail
|
||||
// granularity, and the allow/deny/default overrides.
|
||||
func TestPaymentAvailabilityKillSwitch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newPaymentsService()
|
||||
acc := uuid.New()
|
||||
|
||||
// Fail-open: an untouched rail is enabled.
|
||||
if ok, _, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); err != nil || !ok {
|
||||
t.Fatalf("fail-open: CanPurchase = %v/%v, want true", ok, err)
|
||||
}
|
||||
|
||||
// Disable the web rail with a per-language message → blocked with that message, localized.
|
||||
if err := svc.SetRailStatus(ctx, payments.RailDirectWeb, payments.RailAvailability{MessageRU: "Чиним", MessageEN: "Fixing"}); err != nil {
|
||||
t.Fatalf("set rail status: %v", err)
|
||||
}
|
||||
if ok, reason, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "ru"); err != nil || ok || reason != "Чиним" {
|
||||
t.Fatalf("disabled rail (ru): CanPurchase = %v/%q/%v, want false/Чиним", ok, reason, err)
|
||||
}
|
||||
if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); ok || reason != "Fixing" {
|
||||
t.Fatalf("disabled rail (en): = %v/%q, want false/Fixing", ok, reason)
|
||||
}
|
||||
// Per-rail granularity: another rail stays enabled.
|
||||
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok {
|
||||
t.Fatalf("android rail should stay enabled")
|
||||
}
|
||||
|
||||
// A per-account "allow" override bypasses the disabled rail.
|
||||
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideAllow); err != nil {
|
||||
t.Fatalf("set override allow: %v", err)
|
||||
}
|
||||
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); !ok {
|
||||
t.Fatalf("allow override should bypass the disabled rail")
|
||||
}
|
||||
if ov, err := svc.PurchaseOverrideFor(ctx, acc); err != nil || ov != payments.OverrideAllow {
|
||||
t.Fatalf("PurchaseOverrideFor = %v/%v, want allow", ov, err)
|
||||
}
|
||||
|
||||
// A "deny" override blocks even an enabled rail.
|
||||
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDeny); err != nil {
|
||||
t.Fatalf("set override deny: %v", err)
|
||||
}
|
||||
if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); ok || reason == "" {
|
||||
t.Fatalf("deny override should block the enabled android rail with a reason, got %v/%q", ok, reason)
|
||||
}
|
||||
|
||||
// Clearing the override (default) restores rail-driven behaviour.
|
||||
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDefault); err != nil {
|
||||
t.Fatalf("clear override: %v", err)
|
||||
}
|
||||
if ov, _ := svc.PurchaseOverrideFor(ctx, acc); ov != payments.OverrideDefault {
|
||||
t.Fatalf("after clear, override = %v, want default", ov)
|
||||
}
|
||||
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok {
|
||||
t.Fatalf("after clearing override, android rail should be enabled again")
|
||||
}
|
||||
|
||||
// RailStatuses reflects the stored web-rail change and fills the rest fail-open.
|
||||
all, err := svc.RailStatuses(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("rail statuses: %v", err)
|
||||
}
|
||||
if all[payments.RailDirectWeb].Enabled {
|
||||
t.Fatalf("web rail should read disabled")
|
||||
}
|
||||
if !all[payments.RailVK].Enabled {
|
||||
t.Fatalf("untouched vk rail should read enabled")
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("provision robot: %v", err)
|
||||
}
|
||||
guest, err := st.ProvisionGuest(ctx, "", "")
|
||||
guest, err := st.ProvisionGuest(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision guest: %v", err)
|
||||
}
|
||||
|
||||
@@ -31,39 +31,13 @@ func NewService(emails *account.EmailService, accounts *account.Store, merger *a
|
||||
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
|
||||
}
|
||||
|
||||
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked,
|
||||
// MergeRequired or Merged is set. SecondaryID is the account to be retired when a merge
|
||||
// is required (the caller renders an irreversible-merge confirmation from it). Merged is
|
||||
// set when the caller was a guest, so the merge ran inline (see confirmOrAutoMerge) and
|
||||
// Merge carries its result (the switched-session token for the surviving durable account).
|
||||
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or
|
||||
// MergeRequired is set; SecondaryID is the account to be retired when a merge is
|
||||
// required (the caller renders an irreversible-merge confirmation from it).
|
||||
type ConfirmResult struct {
|
||||
Linked bool
|
||||
MergeRequired bool
|
||||
SecondaryID uuid.UUID
|
||||
Merged bool
|
||||
Merge MergeResult
|
||||
}
|
||||
|
||||
// confirmOrAutoMerge decides a required merge at the confirm step. A durable caller gets
|
||||
// the explicit MergeRequired confirmation (consolidating two real accounts is irreversible
|
||||
// and consequential — the user must see it). A GUEST caller does not: by the guest-primary
|
||||
// rule the durable other account survives and the ephemeral guest is retired, folding its
|
||||
// games/wallet/stats in — from the user's side it is simply "logged into my account", so the
|
||||
// merge runs inline and the client just adopts the switched session. The active-game guard can
|
||||
// still refuse (accountmerge.ErrActiveGameConflict), surfaced to the caller unchanged.
|
||||
func (s *Service) confirmOrAutoMerge(ctx context.Context, callerID, owner uuid.UUID) (ConfirmResult, error) {
|
||||
caller, err := s.accounts.GetByID(ctx, callerID)
|
||||
if err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
if !caller.IsGuest {
|
||||
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
|
||||
}
|
||||
res, err := s.merge(ctx, callerID, owner)
|
||||
if err != nil {
|
||||
return ConfirmResult{}, err
|
||||
}
|
||||
return ConfirmResult{Merged: true, Merge: res}, nil
|
||||
}
|
||||
|
||||
// MergeResult reports a completed merge. PrimaryID is the surviving account.
|
||||
@@ -93,7 +67,7 @@ func (s *Service) ConfirmEmail(ctx context.Context, accountID uuid.UUID, email,
|
||||
}
|
||||
return ConfirmResult{Linked: true}, nil
|
||||
}
|
||||
return s.confirmOrAutoMerge(ctx, accountID, owner)
|
||||
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
|
||||
}
|
||||
|
||||
// MergeEmail re-verifies the code and merges the address's account into the
|
||||
@@ -129,7 +103,7 @@ func (s *Service) ConfirmTelegram(ctx context.Context, callerID uuid.UUID, exter
|
||||
if owner == callerID {
|
||||
return ConfirmResult{Linked: true}, nil
|
||||
}
|
||||
return s.confirmOrAutoMerge(ctx, callerID, owner)
|
||||
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
|
||||
}
|
||||
|
||||
// MergeTelegram merges the account owning a gateway-validated Telegram identity
|
||||
@@ -176,7 +150,7 @@ func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID
|
||||
if owner == callerID {
|
||||
return ConfirmResult{Linked: true}, nil
|
||||
}
|
||||
return s.confirmOrAutoMerge(ctx, callerID, owner)
|
||||
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
|
||||
}
|
||||
|
||||
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package payments
|
||||
|
||||
import "slices"
|
||||
|
||||
// PurchaseOverride is a per-account purchase override that forces purchases allowed or denied for
|
||||
// one account regardless of the operational rail switch, or is absent (OverrideDefault) when the
|
||||
// account follows the rail switch. The zero value is OverrideDefault, so a missing override row maps
|
||||
// to it. OverrideAllow bypasses ONLY the operational rail switch — never the security / compliance
|
||||
// gates (trusted platform, the D36 email anchor, the VK-iOS spend freeze, the min client version),
|
||||
// which CreateOrder enforces separately.
|
||||
type PurchaseOverride int
|
||||
|
||||
const (
|
||||
// OverrideDefault follows the rail switch (no override row for the account).
|
||||
OverrideDefault PurchaseOverride = iota
|
||||
// OverrideAllow always allows the purchase, bypassing only the operational rail switch.
|
||||
OverrideAllow
|
||||
// OverrideDeny always denies the purchase for the account.
|
||||
OverrideDeny
|
||||
)
|
||||
|
||||
// RailAvailability is a payment rail's operational availability: whether purchases are enabled and,
|
||||
// when disabled, the operator's explanation in each language, shown to the user on a purchase
|
||||
// attempt. A rail with no status row is treated as enabled (fail-open — see the store), so the
|
||||
// zero value is never used as a live "enabled" default.
|
||||
type RailAvailability struct {
|
||||
Enabled bool
|
||||
MessageRU string
|
||||
MessageEN string
|
||||
}
|
||||
|
||||
// PurchaseGate decides whether an account may open a purchase order on a rail, from the per-account
|
||||
// override and the rail's operational availability. It is the operational layer only — the caller
|
||||
// still enforces the security gates. On a block it returns a reason localized to lang ("ru", else
|
||||
// English). Fail-open: OverrideDefault on an enabled rail allows.
|
||||
func PurchaseGate(override PurchaseOverride, rail RailAvailability, lang string) (ok bool, reason string) {
|
||||
switch override {
|
||||
case OverrideAllow:
|
||||
return true, "" // bypasses only the ops switch; the security gates still apply upstream
|
||||
case OverrideDeny:
|
||||
return false, defaultUnavailable(lang) // a per-account block — a neutral reason
|
||||
default: // OverrideDefault — follow the rail switch
|
||||
if rail.Enabled {
|
||||
return true, ""
|
||||
}
|
||||
return false, railMessage(rail, lang)
|
||||
}
|
||||
}
|
||||
|
||||
// railMessage returns the operator's rail-off message in lang, falling back to the other language
|
||||
// and then to the built-in default when the operator left both blank.
|
||||
func railMessage(rail RailAvailability, lang string) string {
|
||||
if lang == "ru" {
|
||||
if rail.MessageRU != "" {
|
||||
return rail.MessageRU
|
||||
}
|
||||
if rail.MessageEN != "" {
|
||||
return rail.MessageEN
|
||||
}
|
||||
} else {
|
||||
if rail.MessageEN != "" {
|
||||
return rail.MessageEN
|
||||
}
|
||||
if rail.MessageRU != "" {
|
||||
return rail.MessageRU
|
||||
}
|
||||
}
|
||||
return defaultUnavailable(lang)
|
||||
}
|
||||
|
||||
// defaultUnavailable is the built-in localized "payments unavailable" reason used when the operator
|
||||
// set no custom message, and for a per-account deny.
|
||||
func defaultUnavailable(lang string) string {
|
||||
if lang == "ru" {
|
||||
return "Оплата временно недоступна. Попробуйте позже."
|
||||
}
|
||||
return "Payments are temporarily unavailable. Please try again later."
|
||||
}
|
||||
|
||||
// Rail keys name the operational payment rails the kill switch and the per-account override key on.
|
||||
// The direct rail is split per channel (D42); vk and telegram are single-rail.
|
||||
const (
|
||||
RailDirectWeb = "direct:web"
|
||||
RailDirectAndroid = "direct:android"
|
||||
RailVK = "vk"
|
||||
RailTelegram = "telegram"
|
||||
)
|
||||
|
||||
// KnownRails is the fixed set of rail keys, for the admin editor and for validation.
|
||||
var KnownRails = []string{RailDirectWeb, RailDirectAndroid, RailVK, RailTelegram}
|
||||
|
||||
// RailKey maps a payment context to its operational rail key: the direct rail by channel subtype
|
||||
// (android, else web), or the store rail's own name (vk / telegram).
|
||||
func RailKey(kind Source, subtype string) string {
|
||||
if kind == SourceDirect {
|
||||
if subtype == "android" {
|
||||
return RailDirectAndroid
|
||||
}
|
||||
return RailDirectWeb
|
||||
}
|
||||
return string(kind)
|
||||
}
|
||||
|
||||
// isKnownRail reports whether rail is one of the fixed rail keys.
|
||||
func isKnownRail(rail string) bool {
|
||||
return slices.Contains(KnownRails, rail)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package payments
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPurchaseGate(t *testing.T) {
|
||||
on := RailAvailability{Enabled: true}
|
||||
offMsg := RailAvailability{Enabled: false, MessageRU: "Чиним", MessageEN: "Fixing"}
|
||||
offNoMsg := RailAvailability{Enabled: false}
|
||||
|
||||
// OverrideDefault follows the rail switch.
|
||||
if ok, _ := PurchaseGate(OverrideDefault, on, "en"); !ok {
|
||||
t.Error("default + enabled rail should allow")
|
||||
}
|
||||
if ok, r := PurchaseGate(OverrideDefault, offMsg, "ru"); ok || r != "Чиним" {
|
||||
t.Errorf("default + off rail (ru) = %v/%q, want false/Чиним", ok, r)
|
||||
}
|
||||
if ok, r := PurchaseGate(OverrideDefault, offMsg, "en"); ok || r != "Fixing" {
|
||||
t.Errorf("default + off rail (en) = %v/%q, want false/Fixing", ok, r)
|
||||
}
|
||||
|
||||
// Off rail with no custom message → the built-in default, localized (not the English default in ru).
|
||||
if ok, r := PurchaseGate(OverrideDefault, offNoMsg, "ru"); ok || r != defaultUnavailable("ru") {
|
||||
t.Errorf("default + off no-msg (ru) = %v/%q, want false + the ru default", ok, r)
|
||||
}
|
||||
|
||||
// OverrideAllow bypasses the ops switch even when the rail is off.
|
||||
if ok, r := PurchaseGate(OverrideAllow, offMsg, "en"); !ok || r != "" {
|
||||
t.Errorf("allow + off rail = %v/%q, want true/empty (bypasses the ops switch)", ok, r)
|
||||
}
|
||||
|
||||
// OverrideDeny blocks even when the rail is on, with a non-empty reason.
|
||||
if ok, r := PurchaseGate(OverrideDeny, on, "en"); ok || r == "" {
|
||||
t.Errorf("deny + on rail = %v/%q, want false + a reason", ok, r)
|
||||
}
|
||||
|
||||
// The message falls back to the other language when only one is set.
|
||||
if _, r := PurchaseGate(OverrideDefault, RailAvailability{MessageEN: "OnlyEN"}, "ru"); r != "OnlyEN" {
|
||||
t.Errorf("off rail ru with only EN msg = %q, want OnlyEN (fallback)", r)
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,6 @@ const atomChips = "chips"
|
||||
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
||||
// no method, so only values show; buying is gated server-side regardless.
|
||||
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
||||
// Present products in the canonical listing order shared with the offer and the admin console
|
||||
// (packs first by rouble price, then values grouped hints → no-ads → combo and by chip price); the
|
||||
// client renders them as received. Ordered in place — the caller passes a fresh loadCatalog result
|
||||
// it does not reuse.
|
||||
sortCatalogEntries(entries)
|
||||
var out CatalogView
|
||||
for _, e := range entries {
|
||||
isPack := false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -143,12 +144,10 @@ func validateProduct(in ProductInput, sellable bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminCatalog lists products with their composition, prices and whether each has been transacted,
|
||||
// for the admin editor. includeInactive adds the archived products to the active ones (the console's
|
||||
// active/all toggle); with it false only active products are returned. Read uncached, straight from
|
||||
// the catalog tables.
|
||||
func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||
return s.store.adminCatalog(ctx, includeInactive)
|
||||
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
|
||||
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
|
||||
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
return s.store.adminCatalog(ctx)
|
||||
}
|
||||
|
||||
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
||||
@@ -158,7 +157,18 @@ func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]Adm
|
||||
// products the same as active ones (the admin list shows both).
|
||||
func SortAdminCatalog(products []AdminProduct) {
|
||||
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
||||
return compareCatalogRank(adminRank(a), adminRank(b))
|
||||
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
|
||||
if pa {
|
||||
return -1 // packs (sales) before values (chip exchange)
|
||||
}
|
||||
return 1
|
||||
} else if pa {
|
||||
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
|
||||
}
|
||||
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
|
||||
return d
|
||||
}
|
||||
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// catalogRank is the canonical listing key for a catalog product: whether it is a chip pack, its
|
||||
// value subgroup (see [valueGroup]; unused for a pack) and the minor-unit amount its section sorts by
|
||||
// (a pack's direct rouble price, a value's chip price; math.MaxInt64 when absent, so a misconfigured
|
||||
// row sorts last rather than leading).
|
||||
type catalogRank struct {
|
||||
pack bool
|
||||
group int
|
||||
amount int64
|
||||
}
|
||||
|
||||
// compareCatalogRank is the single canonical product order, shared by the storefront ([projectCatalog]),
|
||||
// the public offer ([projectOfferPricing]) and the admin catalog ([SortAdminCatalog]): chip packs
|
||||
// first, ascending by rouble price; then chip-priced values grouped hints → no-ads → combo →
|
||||
// tournament and, within a group, ascending by chip price. A pack's group is unused (all packs share
|
||||
// one section). Callers use it through [entryRank] / [adminRank], which build the key from each
|
||||
// product shape, so the subgroup and ordering rules live in exactly one place.
|
||||
func compareCatalogRank(a, b catalogRank) int {
|
||||
if a.pack != b.pack {
|
||||
if a.pack {
|
||||
return -1 // packs (sales) before values (chip exchange)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if !a.pack {
|
||||
if d := cmp.Compare(a.group, b.group); d != 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return cmp.Compare(a.amount, b.amount)
|
||||
}
|
||||
|
||||
// entryRank builds the canonical rank of a storefront / offer catalog entry.
|
||||
func entryRank(e catalogEntry) catalogRank {
|
||||
if isPackEntry(e) {
|
||||
return catalogRank{pack: true, amount: offerSortAmount(e, string(SourceDirect), CurrencyRUB)}
|
||||
}
|
||||
return catalogRank{group: offerValueGroup(e), amount: offerSortAmount(e, "", CurrencyChip)}
|
||||
}
|
||||
|
||||
// adminRank builds the canonical rank of an admin catalog product.
|
||||
func adminRank(p AdminProduct) catalogRank {
|
||||
if adminIsPack(p) {
|
||||
return catalogRank{pack: true, amount: adminPriceAmount(p, string(SourceDirect), CurrencyRUB)}
|
||||
}
|
||||
return catalogRank{group: adminValueGroup(p), amount: adminPriceAmount(p, "", CurrencyChip)}
|
||||
}
|
||||
|
||||
// sortCatalogEntries orders storefront / offer entries in place into the canonical listing order
|
||||
// ([compareCatalogRank]). Stable, so equal-keyed products keep their incoming (creation) order.
|
||||
func sortCatalogEntries(entries []catalogEntry) {
|
||||
slices.SortStableFunc(entries, func(a, b catalogEntry) int {
|
||||
return compareCatalogRank(entryRank(a), entryRank(b))
|
||||
})
|
||||
}
|
||||
@@ -135,56 +135,6 @@ func TestProjectCatalog_Empty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectCatalog_CanonicalOrder checks the storefront lists products in the shared canonical order
|
||||
// (compareCatalogRank): chip packs first ascending by rouble price, then chip-priced values grouped
|
||||
// hints → no-ads → combo and ascending by chip price — the same order as the offer and the admin
|
||||
// console, regardless of catalog creation order.
|
||||
func TestProjectCatalog_CanonicalOrder(t *testing.T) {
|
||||
pack := func(title string, rub int64) catalogEntry {
|
||||
return catalogEntry{
|
||||
id: uuid.New(),
|
||||
title: title,
|
||||
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
|
||||
prices: []priceRow{{method: "direct", currency: CurrencyRUB, amount: rub}},
|
||||
}
|
||||
}
|
||||
value := func(title string, chips int64, atoms ...string) catalogEntry {
|
||||
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
|
||||
for _, a := range atoms {
|
||||
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
|
||||
}
|
||||
return e
|
||||
}
|
||||
// Deliberately shuffled on input; the projection must impose the canonical order.
|
||||
entries := []catalogEntry{
|
||||
pack("packDear", 30000),
|
||||
value("bundle", 500, "hints", "noads_days"),
|
||||
pack("packCheap", 10000),
|
||||
value("adsOnly", 150, "noads_days"),
|
||||
value("hintsBig", 200, "hints"),
|
||||
value("hintsSmall", 50, "hints"),
|
||||
}
|
||||
got := projectCatalog(entries, Context{Kind: SourceDirect})
|
||||
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||
if len(got.Products) != len(want) {
|
||||
t.Fatalf("projected %d products, want %d", len(got.Products), len(want))
|
||||
}
|
||||
for i, p := range got.Products {
|
||||
if p.Title != want[i] {
|
||||
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], productTitles(got.Products))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// productTitles extracts the projected product titles for a failure message.
|
||||
func productTitles(products []CatalogProduct) []string {
|
||||
out := make([]string, len(products))
|
||||
for i, p := range products {
|
||||
out[i] = p.Title
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
||||
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
||||
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -59,13 +60,8 @@ func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
|
||||
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
|
||||
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
|
||||
func projectOfferPricing(entries []catalogEntry) string {
|
||||
// Order the whole catalog once by the shared canonical rank, then split it into the two offer
|
||||
// tables (packs first, then the chip-exchange values); each keeps that order. Sort a copy so the
|
||||
// caller's slice is left untouched.
|
||||
sorted := slices.Clone(entries)
|
||||
sortCatalogEntries(sorted)
|
||||
var packs, values []catalogEntry
|
||||
for _, e := range sorted {
|
||||
for _, e := range entries {
|
||||
if isPackEntry(e) {
|
||||
packs = append(packs, e)
|
||||
} else {
|
||||
@@ -73,6 +69,18 @@ func projectOfferPricing(entries []catalogEntry) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price
|
||||
// sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties.
|
||||
slices.SortStableFunc(packs, func(a, b catalogEntry) int {
|
||||
return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB))
|
||||
})
|
||||
slices.SortStableFunc(values, func(a, b catalogEntry) int {
|
||||
if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 {
|
||||
return d
|
||||
}
|
||||
return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip))
|
||||
})
|
||||
|
||||
var b strings.Builder
|
||||
if len(packs) > 0 {
|
||||
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CanPurchase reports whether an account may open a purchase order on rail, combining the account's
|
||||
// per-account override with the rail's operational switch (PurchaseGate). It is the operational
|
||||
// availability layer only — the caller still enforces the security gates (trusted platform, the D36
|
||||
// email anchor, the VK-iOS spend freeze, the min client version). On a block, reason is localized to
|
||||
// lang for the user; err is only a store failure.
|
||||
func (s *Service) CanPurchase(ctx context.Context, accountID uuid.UUID, rail, lang string) (ok bool, reason string, err error) {
|
||||
ov, err := s.store.purchaseOverride(ctx, accountID)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
avail, err := s.store.railAvailability(ctx, rail)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
ok, reason = PurchaseGate(ov, avail, lang)
|
||||
return ok, reason, nil
|
||||
}
|
||||
|
||||
// RailStatuses returns every known rail's operational status for the admin editor, filling a rail
|
||||
// with no stored row with the fail-open enabled default so the editor always shows one row per rail.
|
||||
func (s *Service) RailStatuses(ctx context.Context) (map[string]RailAvailability, error) {
|
||||
stored, err := s.store.allRailStatus(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]RailAvailability, len(KnownRails))
|
||||
for _, rail := range KnownRails {
|
||||
if a, ok := stored[rail]; ok {
|
||||
out[rail] = a
|
||||
} else {
|
||||
out[rail] = RailAvailability{Enabled: true}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetRailStatus upserts a rail's operational status from the admin editor. It rejects an unknown rail
|
||||
// key so a typo cannot create a dead row.
|
||||
func (s *Service) SetRailStatus(ctx context.Context, rail string, a RailAvailability) error {
|
||||
if !isKnownRail(rail) {
|
||||
return fmt.Errorf("payments: unknown rail %q", rail)
|
||||
}
|
||||
return s.store.setRailStatus(ctx, rail, a, s.clock())
|
||||
}
|
||||
|
||||
// PurchaseOverrideFor returns an account's purchase override (OverrideDefault when none is set).
|
||||
func (s *Service) PurchaseOverrideFor(ctx context.Context, accountID uuid.UUID) (PurchaseOverride, error) {
|
||||
return s.store.purchaseOverride(ctx, accountID)
|
||||
}
|
||||
|
||||
// SetPurchaseOverride sets or clears an account's purchase override (OverrideDefault deletes the row).
|
||||
func (s *Service) SetPurchaseOverride(ctx context.Context, accountID uuid.UUID, ov PurchaseOverride) error {
|
||||
return s.store.setPurchaseOverride(ctx, accountID, ov, s.clock())
|
||||
}
|
||||
@@ -46,7 +46,6 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont
|
||||
amount: pack.price,
|
||||
origin: method,
|
||||
provider: provider,
|
||||
shop: directShop(cxt),
|
||||
}
|
||||
if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
|
||||
return OrderResult{}, err
|
||||
@@ -54,16 +53,6 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont
|
||||
return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil
|
||||
}
|
||||
|
||||
// directShop returns the merchant shop (channel) a direct order is issued through — the trusted
|
||||
// platform subtype for the direct rail ("web"/"android"), or "" for any other rail (the per-shop
|
||||
// split is direct-only; D42/D44). Recorded on the order for the admin per-channel breakdown.
|
||||
func directShop(cxt Context) string {
|
||||
if cxt.Kind == SourceDirect {
|
||||
return cxt.Subtype
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
|
||||
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
|
||||
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
|
||||
@@ -168,22 +157,6 @@ 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
|
||||
|
||||
@@ -42,8 +42,7 @@ type RiskInfo struct {
|
||||
}
|
||||
|
||||
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
|
||||
// the column is NULL; Shop is the direct-rail merchant channel the referenced order used (empty for
|
||||
// other rails or order-less rows); Snapshot is the raw purchase/refund JSON (empty when none).
|
||||
// the column is NULL; Snapshot is the raw purchase/refund JSON (empty when none).
|
||||
type LedgerEntry struct {
|
||||
Kind string
|
||||
Source string
|
||||
@@ -53,7 +52,6 @@ type LedgerEntry struct {
|
||||
OrderID string
|
||||
Provider string
|
||||
ProviderPaymentID string
|
||||
Shop string
|
||||
Snapshot string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// railAvailability reads a rail's operational availability. A missing row is fail-open: enabled with
|
||||
// no message, so payments stay on unless an operator explicitly disabled the rail.
|
||||
func (s *Store) railAvailability(ctx context.Context, rail string) (RailAvailability, error) {
|
||||
var a RailAvailability
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT enabled, message_ru, message_en FROM payments.rail_status WHERE rail = $1`, rail).
|
||||
Scan(&a.Enabled, &a.MessageRU, &a.MessageEN)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return RailAvailability{Enabled: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RailAvailability{}, fmt.Errorf("payments: read rail status %q: %w", rail, err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// allRailStatus reads every stored rail-status row for the admin editor, keyed by rail. Rails with
|
||||
// no row are absent (the caller fills them with the fail-open enabled default).
|
||||
func (s *Store) allRailStatus(ctx context.Context) (map[string]RailAvailability, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT rail, enabled, message_ru, message_en FROM payments.rail_status`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payments: read rail statuses: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]RailAvailability)
|
||||
for rows.Next() {
|
||||
var rail string
|
||||
var a RailAvailability
|
||||
if err := rows.Scan(&rail, &a.Enabled, &a.MessageRU, &a.MessageEN); err != nil {
|
||||
return nil, fmt.Errorf("payments: scan rail status: %w", err)
|
||||
}
|
||||
out[rail] = a
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// setRailStatus upserts a rail's operational status (admin).
|
||||
func (s *Store) setRailStatus(ctx context.Context, rail string, a RailAvailability, now time.Time) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO payments.rail_status (rail, enabled, message_ru, message_en, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (rail) DO UPDATE SET enabled = $2, message_ru = $3, message_en = $4, updated_at = $5`,
|
||||
rail, a.Enabled, a.MessageRU, a.MessageEN, now); err != nil {
|
||||
return fmt.Errorf("payments: set rail status %q: %w", rail, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// purchaseOverride reads an account's purchase override; a missing row is OverrideDefault.
|
||||
func (s *Store) purchaseOverride(ctx context.Context, accountID uuid.UUID) (PurchaseOverride, error) {
|
||||
var allow bool
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT allow FROM payments.account_payment_override WHERE account_id = $1`, accountID).Scan(&allow)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return OverrideDefault, nil
|
||||
}
|
||||
if err != nil {
|
||||
return OverrideDefault, fmt.Errorf("payments: read purchase override %s: %w", accountID, err)
|
||||
}
|
||||
if allow {
|
||||
return OverrideAllow, nil
|
||||
}
|
||||
return OverrideDeny, nil
|
||||
}
|
||||
|
||||
// setPurchaseOverride upserts an account's override, or deletes the row for OverrideDefault (the
|
||||
// default state is the absence of a row).
|
||||
func (s *Store) setPurchaseOverride(ctx context.Context, accountID uuid.UUID, ov PurchaseOverride, now time.Time) error {
|
||||
if ov == OverrideDefault {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM payments.account_payment_override WHERE account_id = $1`, accountID); err != nil {
|
||||
return fmt.Errorf("payments: clear purchase override %s: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO payments.account_payment_override (account_id, allow, updated_at) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (account_id) DO UPDATE SET allow = $2, updated_at = $3`,
|
||||
accountID, ov == OverrideAllow, now); err != nil {
|
||||
return fmt.Errorf("payments: set purchase override %s: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -19,17 +19,11 @@ import (
|
||||
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
||||
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
||||
|
||||
// adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the
|
||||
// archived products to the active ones; with it false only active products are returned.
|
||||
func (s *Store) adminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||
where := table.Product.Active.IS_TRUE()
|
||||
if includeInactive {
|
||||
where = postgres.Bool(true)
|
||||
}
|
||||
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
|
||||
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
var prods []model.Product
|
||||
if err := postgres.SELECT(table.Product.AllColumns).
|
||||
FROM(table.Product).
|
||||
WHERE(where).
|
||||
ORDER_BY(table.Product.CreatedAt.ASC()).
|
||||
QueryContext(ctx, s.db, &prods); err != nil {
|
||||
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
||||
|
||||
@@ -151,7 +151,6 @@ type newOrder struct {
|
||||
amount Money
|
||||
origin Source
|
||||
provider string
|
||||
shop string
|
||||
}
|
||||
|
||||
// createOrder inserts a pending order.
|
||||
@@ -160,12 +159,12 @@ func (s *Store) createOrder(ctx context.Context, o newOrder, now time.Time) erro
|
||||
table.Orders.OrderID, table.Orders.AccountID, table.Orders.Platform,
|
||||
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
|
||||
table.Orders.Origin, table.Orders.Status, table.Orders.Provider,
|
||||
table.Orders.CreatedAt, table.Orders.UpdatedAt, table.Orders.Shop,
|
||||
table.Orders.CreatedAt, table.Orders.UpdatedAt,
|
||||
).VALUES(
|
||||
o.orderID, o.accountID, o.platform,
|
||||
o.productID, o.amount.Minor(), string(o.amount.Currency()),
|
||||
string(o.origin), "pending", o.provider,
|
||||
now, now, o.shop,
|
||||
now, now,
|
||||
)
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("payments: create order: %w", err)
|
||||
@@ -414,18 +413,6 @@ 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
|
||||
|
||||
@@ -74,39 +74,9 @@ func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Stat
|
||||
CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Annotate direct-rail entries with the merchant shop (channel) their order was issued through
|
||||
// (E10/D44), keyed by the ledger's order id. Non-direct / order-less entries stay "".
|
||||
shops, err := s.orderShops(ctx, accountID)
|
||||
if err != nil {
|
||||
return Statement{}, err
|
||||
}
|
||||
for i := range out.Ledger {
|
||||
out.Ledger[i].Shop = shops[out.Ledger[i].OrderID]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// orderShops maps an account's order ids (as strings) to the merchant shop (channel) each direct
|
||||
// order was issued through, for annotating the ledger report (E10/D44). Orders with an empty shop
|
||||
// (non-direct or pre-split) are omitted, so a lookup miss yields "".
|
||||
func (s *Store) orderShops(ctx context.Context, accountID uuid.UUID) (map[string]string, error) {
|
||||
var rows []model.Orders
|
||||
if err := postgres.SELECT(table.Orders.OrderID, table.Orders.Shop).
|
||||
FROM(table.Orders).
|
||||
WHERE(table.Orders.AccountID.EQ(postgres.UUID(accountID))).
|
||||
QueryContext(ctx, s.db, &rows); err != nil {
|
||||
return nil, fmt.Errorf("payments: load order shops %s: %w", accountID, err)
|
||||
}
|
||||
m := make(map[string]string, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.Shop != "" {
|
||||
m[r.OrderID.String()] = r.Shop
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export.
|
||||
func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) {
|
||||
var rows []model.Ledger
|
||||
|
||||
@@ -25,5 +25,4 @@ type Orders struct {
|
||||
ProviderPaymentID *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Shop string
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ type ordersTable struct {
|
||||
ProviderPaymentID postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
Shop postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -83,10 +82,9 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
|
||||
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
ShopColumn = postgres.StringColumn("shop")
|
||||
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
|
||||
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
|
||||
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return ordersTable{
|
||||
@@ -105,7 +103,6 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
|
||||
ProviderPaymentID: ProviderPaymentIDColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
Shop: ShopColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Multi-shop direct rail (E10/D44): record which Robokassa merchant shop (channel) issued a direct
|
||||
-- order — "web" / "android" (ios later) — so the admin financial report can break direct payments
|
||||
-- down by channel. Only the direct rail is per-channel; other rails leave it "". Additive only (a
|
||||
-- defaulted column), applies forward via goose with no data rewrite (no contour wipe); an image
|
||||
-- rollback ignores the column.
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE payments.orders
|
||||
ADD COLUMN shop text NOT NULL DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE payments.orders DROP COLUMN shop;
|
||||
@@ -1,26 +0,0 @@
|
||||
-- Payment availability controls (D45/D46): a per-rail operational kill switch with a localized message,
|
||||
-- and a per-account purchase override. Both let an operator disable payments live from the admin —
|
||||
-- a whole rail/channel, or one account — and explain why to the user on a purchase attempt. Additive
|
||||
-- (new tables) — applies forward via goose with no data rewrite (no contour wipe); an image rollback
|
||||
-- ignores both tables. Fail-open by design: a rail with no row is enabled; an account with no row
|
||||
-- follows the rail switch.
|
||||
-- +goose Up
|
||||
|
||||
CREATE TABLE payments.rail_status (
|
||||
rail text PRIMARY KEY,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
message_ru text NOT NULL DEFAULT '',
|
||||
message_en text NOT NULL DEFAULT '',
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE payments.account_payment_override (
|
||||
account_id uuid PRIMARY KEY,
|
||||
allow boolean NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DROP TABLE payments.account_payment_override;
|
||||
DROP TABLE payments.rail_status;
|
||||
@@ -1,54 +0,0 @@
|
||||
package robokassa
|
||||
|
||||
// Shops is a set of Robokassa merchant shops keyed by channel — the subtype of the trusted
|
||||
// X-Platform signal for the direct rail ("web", "android"; "ios" later). The direct rail routes a
|
||||
// payment to the per-channel shop for separate merchant accounts, accounting and receipts, while
|
||||
// every shop still credits the one direct wallet (docs/PAYMENTS_DECISIONS_ru.md D42). An empty set
|
||||
// leaves the direct order and Result-callback endpoints unregistered.
|
||||
type Shops map[string]Config
|
||||
|
||||
// Channel constants name the direct-rail X-Platform subtypes a shop is keyed by. ChannelWeb is the
|
||||
// default: an unknown or empty channel routes here on the order side, and the legacy single-shop
|
||||
// configuration seeds it.
|
||||
const (
|
||||
ChannelWeb = "web"
|
||||
ChannelAndroid = "android"
|
||||
)
|
||||
|
||||
// Shop returns the shop that issues an order for channel, falling back to the web shop when channel
|
||||
// is unknown, empty or not configured. The fallback is safe: routing only chooses the merchant
|
||||
// account and receipt, never the credited wallet (always direct, D42), so a mis-attributed channel
|
||||
// costs at most accounting accuracy, not money. The second result is false when neither the channel
|
||||
// nor the web shop has a merchant login (the rail is unconfigured).
|
||||
func (s Shops) Shop(channel string) (Config, bool) {
|
||||
if channel != "" {
|
||||
if c, ok := s[channel]; ok && c.MerchantLogin != "" {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
if c, ok := s[ChannelWeb]; ok && c.MerchantLogin != "" {
|
||||
return c, true
|
||||
}
|
||||
return Config{}, false
|
||||
}
|
||||
|
||||
// Verifier returns the shop whose Password2 verifies a Result callback delivered to channel's
|
||||
// dedicated Result route. Unlike Shop it does not fall back to web: each shop's callback is verified
|
||||
// only by that shop's own credentials, so a route with no configured shop reports false (and its
|
||||
// handler answers as unregistered). The second result is false when channel has no merchant login.
|
||||
func (s Shops) Verifier(channel string) (Config, bool) {
|
||||
if c, ok := s[channel]; ok && c.MerchantLogin != "" {
|
||||
return c, true
|
||||
}
|
||||
return Config{}, false
|
||||
}
|
||||
|
||||
// Configured reports whether at least one shop has a merchant login (the direct rail is live).
|
||||
func (s Shops) Configured() bool {
|
||||
for _, c := range s {
|
||||
if c.MerchantLogin != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package robokassa
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShopsShopRouting(t *testing.T) {
|
||||
shops := Shops{
|
||||
ChannelWeb: {MerchantLogin: "webshop", Password1: "w1", Password2: "w2"},
|
||||
ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"},
|
||||
}
|
||||
// Order side: the exact channel wins.
|
||||
if c, ok := shops.Shop(ChannelAndroid); !ok || c.MerchantLogin != "androidshop" {
|
||||
t.Errorf("Shop(android) = %q/%v, want androidshop/true", c.MerchantLogin, ok)
|
||||
}
|
||||
// Order side: an unknown or empty channel falls back to the web shop (safe — always credits direct).
|
||||
if c, ok := shops.Shop("ios"); !ok || c.MerchantLogin != "webshop" {
|
||||
t.Errorf("Shop(ios) = %q/%v, want webshop/true (web fallback)", c.MerchantLogin, ok)
|
||||
}
|
||||
if c, ok := shops.Shop(""); !ok || c.MerchantLogin != "webshop" {
|
||||
t.Errorf("Shop(empty) = %q/%v, want webshop/true (web fallback)", c.MerchantLogin, ok)
|
||||
}
|
||||
// No web shop and an unknown channel → unconfigured.
|
||||
if _, ok := (Shops{ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"}}).Shop("ios"); ok {
|
||||
t.Error("Shop(ios) with no web shop returned ok, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShopsVerifierIsStrict(t *testing.T) {
|
||||
shops := Shops{
|
||||
ChannelWeb: {MerchantLogin: "webshop", Password1: "w1", Password2: "w2"},
|
||||
ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"},
|
||||
}
|
||||
// Callback side: the exact channel's own credentials, no web fallback (each callback is verified
|
||||
// only by its own shop's Password2).
|
||||
if c, ok := shops.Verifier(ChannelAndroid); !ok || c.MerchantLogin != "androidshop" {
|
||||
t.Errorf("Verifier(android) = %q/%v, want androidshop/true", c.MerchantLogin, ok)
|
||||
}
|
||||
if _, ok := shops.Verifier("ios"); ok {
|
||||
t.Error("Verifier(ios) returned ok, want false (no fallback)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShopsConfigured(t *testing.T) {
|
||||
if (Shops{}).Configured() {
|
||||
t.Error("an empty set reported configured")
|
||||
}
|
||||
if (Shops{ChannelWeb: {}}).Configured() {
|
||||
t.Error("a shop with no merchant login reported configured")
|
||||
}
|
||||
if !(Shops{ChannelWeb: {MerchantLogin: "s", Password1: "1", Password2: "2"}}).Configured() {
|
||||
t.Error("a configured shop reported not configured")
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func (s *Server) registerRoutes() {
|
||||
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
|
||||
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
|
||||
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
|
||||
if s.robokassa.Configured() {
|
||||
if s.robokassa.MerchantLogin != "" {
|
||||
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,10 @@ var priceFields = []struct {
|
||||
{"price_chip", "", payments.CurrencyChip},
|
||||
}
|
||||
|
||||
// consoleCatalog lists the catalog products with their composition, prices, transacted flag, and the
|
||||
// inline create form. It shows active products by default; ?all=1 also lists the archived ones (the
|
||||
// active/all toggle).
|
||||
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
|
||||
// flag, and the inline create form.
|
||||
func (s *Server) consoleCatalog(c *gin.Context) {
|
||||
showAll := c.Query("all") == "1"
|
||||
products, err := s.payments.AdminCatalog(c.Request.Context(), showAll)
|
||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
@@ -46,58 +44,13 @@ 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)
|
||||
payout, daily, hourly, err := s.payments.RewardConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
rails, err := s.payments.RailStatuses(c.Request.Context())
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
|
||||
for _, rail := range payments.KnownRails {
|
||||
a := rails[rail]
|
||||
view.Rails = append(view.Rails, adminconsole.RailStatusRow{Rail: rail, Enabled: a.Enabled, MessageRU: a.MessageRU, MessageEN: a.MessageEN})
|
||||
}
|
||||
var view adminconsole.CatalogView
|
||||
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)
|
||||
}
|
||||
|
||||
// consoleSetRailStatus toggles a payment rail's operational kill switch and its user-facing
|
||||
// off-message (per language) from the catalog page's payment-availability form. An unchecked box
|
||||
// disables the rail; an unknown rail is refused by the service.
|
||||
func (s *Server) consoleSetRailStatus(c *gin.Context) {
|
||||
rail := strings.TrimSpace(c.PostForm("rail"))
|
||||
a := payments.RailAvailability{
|
||||
Enabled: c.PostForm("enabled") == "on",
|
||||
MessageRU: strings.TrimSpace(c.PostForm("message_ru")),
|
||||
MessageEN: strings.TrimSpace(c.PostForm("message_en")),
|
||||
}
|
||||
if err := s.payments.SetRailStatus(c.Request.Context(), rail, a); err != nil {
|
||||
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Saved", "payment availability 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}
|
||||
@@ -116,7 +69,7 @@ func (s *Server) consoleCatalogDetail(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
products, err := s.payments.AdminCatalog(c.Request.Context(), true) // include archived: the detail form edits any product
|
||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
@@ -289,7 +242,7 @@ func (s *Server) consoleGrantProduct(c *gin.Context) {
|
||||
// bundles, including archived ones — chips and tournament products are excluded).
|
||||
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||||
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
||||
products, err := s.payments.AdminCatalog(ctx, true)
|
||||
products, err := s.payments.AdminCatalog(ctx)
|
||||
if err != nil {
|
||||
return fv
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
gm.POST("/users/:id/grant", s.consoleGrant)
|
||||
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
|
||||
gm.POST("/users/:id/refund", s.consoleRefund)
|
||||
gm.POST("/users/:id/purchase-override", s.consoleSetPurchaseOverride)
|
||||
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
||||
gm.GET("/reasons", s.consoleReasons)
|
||||
gm.POST("/reasons", s.consoleCreateReason)
|
||||
@@ -113,8 +112,6 @@ 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.POST("/catalog/rail-status", s.consoleSetRailStatus)
|
||||
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||
@@ -463,9 +460,6 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
||||
}
|
||||
view.Grant = s.grantForm(ctx)
|
||||
if ov, oerr := s.payments.PurchaseOverrideFor(ctx, id); oerr == nil {
|
||||
view.PurchaseOverride = overrideName(ov)
|
||||
}
|
||||
}
|
||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||
}
|
||||
@@ -487,54 +481,13 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView {
|
||||
for _, e := range stmt.Ledger {
|
||||
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
|
||||
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
|
||||
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Shop: e.Shop, Snapshot: e.Snapshot,
|
||||
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Snapshot: e.Snapshot,
|
||||
At: fmtTime(e.CreatedAt),
|
||||
})
|
||||
}
|
||||
return fv
|
||||
}
|
||||
|
||||
// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny").
|
||||
func overrideName(ov payments.PurchaseOverride) string {
|
||||
switch ov {
|
||||
case payments.OverrideAllow:
|
||||
return "allow"
|
||||
case payments.OverrideDeny:
|
||||
return "deny"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
// consoleSetPurchaseOverride sets or clears an account's per-account purchase override (allow / deny
|
||||
// / default) from the user card. "allow" bypasses only the operational rail switch, never the
|
||||
// security gates; "default" clears the override (deletes the row).
|
||||
func (s *Server) consoleSetPurchaseOverride(c *gin.Context) {
|
||||
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/users/" + id.String()
|
||||
if s.payments == nil {
|
||||
s.renderConsoleMessage(c, "Unavailable", "payments are not configured", back)
|
||||
return
|
||||
}
|
||||
var ov payments.PurchaseOverride
|
||||
switch c.PostForm("override") {
|
||||
case "allow":
|
||||
ov = payments.OverrideAllow
|
||||
case "deny":
|
||||
ov = payments.OverrideDeny
|
||||
default:
|
||||
ov = payments.OverrideDefault
|
||||
}
|
||||
if err := s.payments.SetPurchaseOverride(c.Request.Context(), id, ov); err != nil {
|
||||
s.renderConsoleMessage(c, "Update failed", err.Error(), back)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Saved", "the purchase override was updated", back)
|
||||
}
|
||||
|
||||
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
|
||||
// user card renders.
|
||||
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
|
||||
|
||||
@@ -160,20 +160,16 @@ type guestAuthRequest struct {
|
||||
// Subtype is the client-reported device family (ios/android/web) of this direct
|
||||
// (web/native) session; there is no external signer, so it is recorded best-effort.
|
||||
Subtype string `json:"subtype"`
|
||||
// Language is the client's detected interface locale, seeding the fresh guest's
|
||||
// interface language (best-effort; an unsupported/absent value keeps the 'en' default).
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session,
|
||||
// seeding its time zone from the optional detected browser offset and its interface
|
||||
// language from the optional detected locale.
|
||||
// seeding its time zone from the optional detected browser offset.
|
||||
func (s *Server) handleGuestAuth(c *gin.Context) {
|
||||
// The body is optional: an absent or malformed one simply yields no time-zone/language seed
|
||||
// (the account keeps the UTC / 'en' defaults), so a bind error must not fail the bootstrap.
|
||||
// The body is optional: an absent or malformed one simply yields no time-zone seed
|
||||
// (the account keeps the UTC default), so a bind error must not fail the bootstrap.
|
||||
var req guestAuthRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ, req.Language)
|
||||
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/robokassa"
|
||||
)
|
||||
|
||||
// Ledger/order provider tags per rail.
|
||||
@@ -67,25 +66,9 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
// Operational availability gate (D45/D46): the per-rail kill switch + the per-account override,
|
||||
// before any order is opened. Orthogonal to the security gates in the rail branches below — a
|
||||
// per-account "allow" override bypasses only this switch, never those. The reason is localized to
|
||||
// the account's language for the user.
|
||||
lang := ""
|
||||
if acc, aerr := s.accounts.GetByID(ctx, uid); aerr == nil {
|
||||
lang = acc.PreferredLanguage
|
||||
}
|
||||
if ok, reason, aerr := s.payments.CanPurchase(ctx, uid, payments.RailKey(cxt.Kind, cxt.Subtype), lang); aerr != nil {
|
||||
s.abortErr(c, aerr)
|
||||
return
|
||||
} else if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable, errorResponse{Error: errorBody{Code: "payment_unavailable", Message: reason}})
|
||||
return
|
||||
}
|
||||
switch cxt.Kind {
|
||||
case payments.SourceDirect:
|
||||
shop, ok := s.robokassa.Shop(cxt.Subtype)
|
||||
if !ok {
|
||||
if s.robokassa.MerchantLogin == "" {
|
||||
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
|
||||
return
|
||||
}
|
||||
@@ -106,7 +89,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, walletOrderResponse{
|
||||
OrderID: res.OrderID.String(),
|
||||
RedirectURL: shop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||
Rail: providerRobokassa,
|
||||
})
|
||||
case payments.SourceVK:
|
||||
@@ -153,16 +136,7 @@ func (s *Server) handleRobokassaResult(c *gin.Context) {
|
||||
for k, val := range params {
|
||||
v.Set(k, val)
|
||||
}
|
||||
// The per-shop Result route carries the channel as ?channel=; the legacy bare route defaults to
|
||||
// the web shop. Each channel is verified only by its own shop's Password2 (Verifier is strict).
|
||||
channel := c.DefaultQuery("channel", robokassa.ChannelWeb)
|
||||
shop, ok := s.robokassa.Verifier(channel)
|
||||
if !ok {
|
||||
s.log.Warn("robokassa result: unknown shop channel", zap.String("channel", channel))
|
||||
c.String(http.StatusBadRequest, "bad sign")
|
||||
return
|
||||
}
|
||||
orderID, outSum, ok := shop.VerifyResult(v)
|
||||
orderID, outSum, ok := s.robokassa.VerifyResult(v)
|
||||
if !ok {
|
||||
s.log.Warn("robokassa result: bad signature")
|
||||
c.String(http.StatusBadRequest, "bad sign")
|
||||
|
||||
@@ -285,12 +285,6 @@ func (s *Server) handleLinkVKMerge(c *gin.Context) {
|
||||
// or a completed link (the active account's refreshed profile).
|
||||
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
||||
ctx := c.Request.Context()
|
||||
if res.Merged {
|
||||
// A guest initiator's merge ran inline (the guest-primary rule; no confirmation step),
|
||||
// so render the completed merge — the client adopts the switched session and lands on
|
||||
// the surviving durable account as if it simply logged in.
|
||||
return s.mergeResultResponse(c, res.Merge)
|
||||
}
|
||||
if res.MergeRequired {
|
||||
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
|
||||
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
|
||||
|
||||
@@ -115,9 +115,9 @@ type Deps struct {
|
||||
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
||||
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
||||
Renderer *render.Client
|
||||
// Robokassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
|
||||
// set leaves the order and Result-callback endpoints unregistered.
|
||||
Robokassa robokassa.Shops
|
||||
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
|
||||
// order and Result-callback endpoints unregistered.
|
||||
Robokassa robokassa.Config
|
||||
}
|
||||
|
||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||
@@ -146,7 +146,7 @@ type Server struct {
|
||||
ads *ads.Service
|
||||
payments *payments.Service
|
||||
gamelimits *gamelimits.Service
|
||||
robokassa robokassa.Shops
|
||||
robokassa robokassa.Config
|
||||
notifier notify.Publisher
|
||||
console *adminconsole.Renderer
|
||||
exportKey []byte
|
||||
|
||||
@@ -56,9 +56,6 @@ func Middleware(logger *zap.Logger) gin.HandlerFunc {
|
||||
zap.String("path", route),
|
||||
zap.Int("status", status),
|
||||
zap.Duration("latency", elapsed),
|
||||
// The gateway forwards the real caller as X-Forwarded-For (and Caddy does for /_gm), which
|
||||
// gin resolves here — so the access log carries the client IP, not the gateway's connection.
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
}
|
||||
fields = append(fields, TraceFieldsFromContext(ctx)...)
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
|
||||
|
||||
# --- UI build args (baked into the gateway image) ---------------------------
|
||||
VITE_TELEGRAM_BOT_ID=
|
||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://telegram.me/<bot>/<app>)
|
||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
||||
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
||||
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
||||
@@ -142,18 +142,6 @@ ROBOKASSA_MERCHANT_LOGIN=
|
||||
ROBOKASSA_PASSWORD1=
|
||||
ROBOKASSA_PASSWORD2=
|
||||
ROBOKASSA_TEST=
|
||||
# Multi-shop direct rail (D42): the vars above seed the "web" channel; add per-channel shops here.
|
||||
# ROBOKASSA_WEB_* overrides the web shop; ROBOKASSA_ANDROID_* adds the android (RuStore) shop. Each
|
||||
# credits the one direct wallet; an empty login drops that channel (routing falls back to web). The
|
||||
# Robokassa cabinet points each shop's Result URL at /pay/robokassa/result/web and .../android.
|
||||
ROBOKASSA_WEB_MERCHANT_LOGIN=
|
||||
ROBOKASSA_WEB_PASSWORD1=
|
||||
ROBOKASSA_WEB_PASSWORD2=
|
||||
ROBOKASSA_WEB_TEST=
|
||||
ROBOKASSA_ANDROID_MERCHANT_LOGIN=
|
||||
ROBOKASSA_ANDROID_PASSWORD1=
|
||||
ROBOKASSA_ANDROID_PASSWORD2=
|
||||
ROBOKASSA_ANDROID_TEST=
|
||||
|
||||
# --- Gateway anti-abuse ------------------------------------------------------
|
||||
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
||||
|
||||
@@ -79,7 +79,6 @@ compose binds from this directory.
|
||||
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. |
|
||||
| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) |
|
||||
| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. |
|
||||
| `BACKEND_ROBOKASSA_{WEB,ANDROID}_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | secret / variable | Multi-shop direct rail (D42): per-channel Robokassa shops. The legacy `BACKEND_ROBOKASSA_*` seeds the **web** shop; `…_WEB_*` overrides it, `…_ANDROID_*` adds the **android** (RuStore) shop. Each credits the one `direct` wallet; the cabinet Result URL per shop is `/pay/robokassa/result/{web,android}`. Empty login ⇒ that channel unconfigured (order routing falls back to the web shop). |
|
||||
|
||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
||||
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
||||
@@ -98,7 +97,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
||||
| --- | --- | --- | --- |
|
||||
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
||||
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
|
||||
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image (build-arg). **Deploy-authoritative**: a redeploy that bumped it **delivers** the version onto the volume (add-only) and **activates** it for new games — old games keep the version they pin, and a newer out-of-band console upload is never downgraded (ARCHITECTURE.md §5). The `.seed_version` marker still guards the flat seed's label (a bump is delivered as a new subdirectory, never a relabel). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
|
||||
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
|
||||
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
|
||||
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
|
||||
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
|
||||
@@ -108,8 +107,8 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
||||
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
||||
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
|
||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://telegram.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://telegram.me/Erudit_Game`). |
|
||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
||||
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
|
||||
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
|
||||
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
||||
@@ -176,14 +175,10 @@ For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`);
|
||||
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
|
||||
default — a missing value fails loudly instead of baking a stale tag.
|
||||
|
||||
Bumping `DICT_VERSION` and **redeploying** takes a live contour/prod to the new dictionary:
|
||||
on boot the backend **delivers** the build's version onto the volume (add-only, into
|
||||
`BACKEND_DICT_DIR/<version>/` from the unshadowed `BACKEND_DICT_SEED_DIR`) and **activates** it,
|
||||
so new games pin it while in-flight games keep the version they started on. The `.seed_version`
|
||||
marker still guards the flat seed's label (the delivery is a new subdirectory, never a relabel).
|
||||
The admin console `/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm)
|
||||
remains for an **out-of-band** update without a redeploy; a console version newer than the build
|
||||
survives a later restart on an older image (ARCHITECTURE.md §5).
|
||||
Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the
|
||||
seed-drift guard). A running contour/prod moves to a new release **through the admin console**
|
||||
`/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games
|
||||
keep their pinned version, new games use the new one (ARCHITECTURE.md §5).
|
||||
|
||||
## Production rollout
|
||||
|
||||
@@ -261,8 +256,8 @@ web/prod rollout — a signed APK uploaded to RuStore by hand. The build/release
|
||||
the SDK is missing or unreadable.
|
||||
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
|
||||
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
|
||||
then set the Gitea **secrets** `ANDROID_RUSTORE_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
|
||||
`ANDROID_RUSTORE_KEYSTORE_PASSWORD`, `ANDROID_RUSTORE_KEY_ALIAS`, `ANDROID_RUSTORE_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
|
||||
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
|
||||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
|
||||
variable to the store listing once published (empty until then — the in-app update button no-ops).
|
||||
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
|
||||
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
|
||||
|
||||
@@ -150,13 +150,6 @@
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# Pin every host to UTC so host-level timestamps (journald, file mtimes, cron) line up
|
||||
# across the fleet — some VPS images ship a local zone (the tg host came up on MSK). The
|
||||
# services themselves run in UTC regardless; this is about host-side log correlation.
|
||||
- name: Set the system timezone to UTC
|
||||
community.general.timezone:
|
||||
name: Etc/UTC
|
||||
|
||||
# --- Swap file (OOM cushion) ---------------------------------------------------
|
||||
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
||||
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
||||
|
||||
@@ -171,18 +171,6 @@ services:
|
||||
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
|
||||
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
|
||||
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
|
||||
# Per-channel shops for the multi-shop direct rail (D42): the legacy ROBOKASSA_* above seeds
|
||||
# the "web" channel; ROBOKASSA_WEB_* overrides it and ROBOKASSA_ANDROID_* adds the android
|
||||
# shop. Each shop credits the one direct wallet; an empty login drops that channel (order
|
||||
# routing falls back to the web shop). Result URLs: /pay/robokassa/result/{web,android}.
|
||||
BACKEND_ROBOKASSA_WEB_MERCHANT_LOGIN: ${ROBOKASSA_WEB_MERCHANT_LOGIN:-}
|
||||
BACKEND_ROBOKASSA_WEB_PASSWORD1: ${ROBOKASSA_WEB_PASSWORD1:-}
|
||||
BACKEND_ROBOKASSA_WEB_PASSWORD2: ${ROBOKASSA_WEB_PASSWORD2:-}
|
||||
BACKEND_ROBOKASSA_WEB_TEST: ${ROBOKASSA_WEB_TEST:-}
|
||||
BACKEND_ROBOKASSA_ANDROID_MERCHANT_LOGIN: ${ROBOKASSA_ANDROID_MERCHANT_LOGIN:-}
|
||||
BACKEND_ROBOKASSA_ANDROID_PASSWORD1: ${ROBOKASSA_ANDROID_PASSWORD1:-}
|
||||
BACKEND_ROBOKASSA_ANDROID_PASSWORD2: ${ROBOKASSA_ANDROID_PASSWORD2:-}
|
||||
BACKEND_ROBOKASSA_ANDROID_TEST: ${ROBOKASSA_ANDROID_TEST:-}
|
||||
# The dictionary lives on a named volume seeded from the image on first boot
|
||||
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
|
||||
# inherits). The admin console writes new version subdirectories here, and the
|
||||
|
||||
@@ -107,10 +107,8 @@ dropped). Horizontal scaling is explicit future work.
|
||||
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
||||
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
||||
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
||||
implicit** — detected from failed calls, a reachability probe, a **live-stream heartbeat watchdog**
|
||||
(a silence past ~25 s of the gateway's 10 s keep-alive `heartbeat` means a silently-dead stream — e.g.
|
||||
the radio was cut with no stream error; `ui/src/lib/streamwatchdog.ts`, background-aware) and the OS hint
|
||||
(`navigator` / `@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
||||
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
|
||||
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
||||
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
|
||||
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
|
||||
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
|
||||
@@ -118,17 +116,10 @@ dropped). Horizontal scaling is explicit future work.
|
||||
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
|
||||
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
|
||||
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
|
||||
drop/recovery feeds the same machine. **Telegram/VK are exempt from the offline-*play* model** —
|
||||
`offlineMode.active` is hard-gated on `offlineCapable()` (false in those mini-apps), so nothing — not
|
||||
even a version lock — puts them in offline mode: no blue chrome, no local lobby, no transport kill
|
||||
switch and no device-local create paths. The machine still tracks reachability there, though: a
|
||||
connection lost **inside an online game** is surfaced on **every platform**, driven off the machine's
|
||||
confirmed-offline state (`netState.offline`, not `offlineMode.active`) — the game screen shows a
|
||||
*connection lost* banner, **freezes** the tray and move controls, and hides the resign, add-friend/block
|
||||
and chat/dictionary controls (a started move stays a draft, re-committed by the player on reconnect), and
|
||||
the word-check tool falls back to the game's pinned on-device dictionary (`ui/src/lib/dict/check.ts`,
|
||||
exact when that dawg is cached, else *unavailable*) with its network-only complaint and external
|
||||
look-up hidden.
|
||||
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an
|
||||
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
|
||||
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
|
||||
local lobby, no transport kill switch and no device-local create paths.
|
||||
**Edge hardening:** every request body on the public listener is capped at
|
||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||
@@ -151,10 +142,7 @@ dropped). Horizontal scaling is explicit future work.
|
||||
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
||||
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
||||
requests, plus the caller's real IP as **`X-Forwarded-For`** on **every** call
|
||||
(carried on the request context), so the backend records the real caller — the
|
||||
account's last-login IP, chat/feedback moderation, the access log — rather than
|
||||
the gateway's own connection address; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||
gateway's REST client widens its keep-alive pool well past the stdlib default
|
||||
of 2 idle connections per host; otherwise the per-request connection churn
|
||||
exhausts ephemeral ports and burns gateway CPU under load (see
|
||||
@@ -446,27 +434,22 @@ Key points:
|
||||
version while new games use the new one. A restart re-loads every resident
|
||||
version via `engine.OpenWithVersions` (the flat seed plus each subdirectory,
|
||||
skipping the `.staging/` upload area) and restores the active pointer from
|
||||
`dictionary_state`. The volume preserves uploaded versions across redeploys, so
|
||||
in-flight games keep replaying on the version they pin. The build's
|
||||
`BACKEND_DICT_VERSION` is **deploy-authoritative**: on boot `engine.DeliverVersion`
|
||||
copies the image's DAWGs — kept unshadowed at `BACKEND_DICT_SEED_DIR`, since the
|
||||
volume mount hides the image's `BACKEND_DICT_DIR` copy — into
|
||||
`BACKEND_DICT_DIR/<version>/` when that version is not already resident (**add-only**:
|
||||
the flat seed and prior uploads are never touched), and `InitActiveVersion` makes it
|
||||
the active version. So a redeploy that bumped `DICT_VERSION` moves new games to the
|
||||
new dictionary automatically, while old games stay on theirs — no console step. The
|
||||
one exception: a version installed out-of-band through the console that is **newer**
|
||||
than the build version and still resident is kept, so a restart on an older image never
|
||||
downgrades a hotfix (versions compare numerically). Because the flat DAWGs carry no
|
||||
embedded version, `OpenWithVersions` records the version the flat directory was first
|
||||
seeded at in a `.seed_version` marker and treats it as authoritative for **that flat
|
||||
directory** (the **seed-drift guard**): a later `BACKEND_DICT_VERSION` never relabels
|
||||
the already-seeded flat bytes — it is delivered as a **new subdirectory** instead — so
|
||||
the guard still prevents mis-serving a game pinned to the flat label. The console
|
||||
remains the way to update a dictionary **without** a redeploy (an out-of-band upload).
|
||||
Set `DICT_VERSION` per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`. (The
|
||||
dictionaries ship as a versioned **release artifact** from the `scrabble-dictionary`
|
||||
repo.)
|
||||
`dictionary_state`. The volume preserves uploaded versions across redeploys;
|
||||
once seeded it is not re-seeded, so after bootstrap dictionary changes go through
|
||||
the console rather than a rebuild. Because the flat DAWGs carry no embedded
|
||||
version, `OpenWithVersions` records the version the flat directory was first seeded
|
||||
at in a `.seed_version` marker on the volume and treats that marker as
|
||||
**authoritative** (the **seed-drift guard**): on an already-seeded volume a later
|
||||
`BACKEND_DICT_VERSION` is ignored, so a bumped build seed cannot relabel the
|
||||
already-seeded bytes — which would otherwise silently serve the wrong dictionary
|
||||
and void games pinned to the prior label. A running contour therefore moves to a
|
||||
new release **through the console** (the prior version stays resident, so its games
|
||||
keep replaying), and `DICT_VERSION` is the seed for a **fresh** volume only:
|
||||
bumping it on a live contour is a harmless no-op that takes effect on the next
|
||||
fresh volume. Set it per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`.
|
||||
(The dictionaries ship as a versioned **release artifact** from the
|
||||
`scrabble-dictionary` repo; the build's `DICT_VERSION` selects
|
||||
only the seed.)
|
||||
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
|
||||
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
|
||||
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
|
||||
@@ -1504,8 +1487,7 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
||||
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
||||
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
||||
used for the gateway's per-IP rate limiting and, forwarded on to the backend, the account's
|
||||
last-login IP, chat/feedback moderation and the access log — survives the
|
||||
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
|
||||
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
||||
real peer, so the single config is correct and spoof-safe in both contours. The
|
||||
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
|
||||
|
||||
@@ -50,7 +50,7 @@ tail — at a real control, and a **tap anywhere** advances to the next; after t
|
||||
overlay is gone for good. Two independent series run. The **lobby** series points at the
|
||||
⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game
|
||||
board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange,
|
||||
✨ hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
|
||||
🛟 hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
|
||||
so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the
|
||||
lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the
|
||||
lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the
|
||||
@@ -117,15 +117,12 @@ First platform contact auto-provisions a durable account. From the profile a pla
|
||||
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
|
||||
who links their first identity becomes a durable account. The "already taken" status
|
||||
of an identity is never revealed before the code/sign-in is verified. If the linked
|
||||
identity already belongs to another account, the two accounts are merged into one
|
||||
(statistics summed, games and friends transferred, wallet folded, duplicates removed).
|
||||
A **durable** initiator is shown an explicit, **irreversible** confirmation first —
|
||||
consolidating two real accounts is consequential. A **guest** initiator is not: the
|
||||
durable account that owns the identity is kept, and the ephemeral guest — with its games,
|
||||
wallet and stats folded in — is retired **seamlessly**, so from the player's side it is
|
||||
simply signing into their account (the app switches to it with no prompt). A merge is
|
||||
blocked only while the two accounts share a game still in progress; a guest is then asked
|
||||
to finish that shared game before signing in.
|
||||
identity already belongs to another account, the player is shown an explicit,
|
||||
**irreversible** confirmation and the two accounts are merged into the one they are
|
||||
using (statistics summed, games and friends transferred, duplicates removed) — except
|
||||
when a guest links an identity that already has a durable account, where the durable
|
||||
account is kept and the guest's games move into it. A merge is blocked only while the
|
||||
two accounts share a game still in progress.
|
||||
|
||||
The profile lists the account's **sign-in methods**. On the web a player can add
|
||||
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
|
||||
@@ -232,10 +229,7 @@ brief warm-up shows on the first open otherwise — and falls back to the server
|
||||
local dictionary is unavailable. The dictionary check tool is
|
||||
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
|
||||
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||||
English) to look it up. While offline in an online game the check runs against the game's own
|
||||
dictionary on the device — the same verdict as the server when that dictionary is available,
|
||||
otherwise it reads *unavailable offline* — and the complaint and the external look-up, which both
|
||||
need the network, are hidden. Hints are governed per game —
|
||||
English) to look it up. Hints are governed per game —
|
||||
whether they are allowed and how many each player starts with — and draw on a
|
||||
personal hint wallet once the per-game allowance is spent. Against the robot
|
||||
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
||||
@@ -328,17 +322,6 @@ offline on its own, and restoring the network returns it online by itself. There
|
||||
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
|
||||
offline, so you are never interrupted for a hiccup.
|
||||
|
||||
**Losing the connection while inside an online game** is surfaced on **every platform, including the
|
||||
Telegram/VK mini-apps** — unlike the offline-play mode above, which is web/native only — because an
|
||||
online game needs the server for every move. A slim *connection lost* banner appears at the top of the
|
||||
board and the play area **freezes**: the rack and the move controls disable, and the resign, the
|
||||
add-friend/block and the chat/dictionary controls are hidden, while a move you had started stays on the
|
||||
board as a draft. Nothing
|
||||
is sent; when the connection returns the banner clears, the controls come back and you commit the move
|
||||
yourself. If you were already in the chat or the dictionary when the drop happened you stay there — chat
|
||||
becomes read-only (send and nudge disable) and the dictionary keeps checking words against the game's
|
||||
on-device dictionary.
|
||||
|
||||
### Staying up to date
|
||||
When a new client version is required, the app does not lock you out. On the **native** app a
|
||||
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
|
||||
|
||||
@@ -52,7 +52,7 @@ Telegram-бот, который документы указывают как к
|
||||
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
|
||||
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
|
||||
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
|
||||
✨ подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
|
||||
🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
|
||||
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
|
||||
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
|
||||
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
|
||||
@@ -121,14 +121,12 @@ Telegram держит **единого бота**: все игроки поль
|
||||
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
||||
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
||||
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
|
||||
принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется,
|
||||
игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному**
|
||||
инициатору сначала показывают явное **необратимое** подтверждение — объединение двух
|
||||
настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец
|
||||
личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится
|
||||
**бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение
|
||||
переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть
|
||||
общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию.
|
||||
принадлежит другому аккаунту, игроку показывают явное **необратимое**
|
||||
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
|
||||
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
|
||||
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
|
||||
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
|
||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||
|
||||
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
||||
@@ -238,9 +236,7 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||
чтобы его посмотреть. В офлайне в онлайн-партии проверка идёт по собственному словарю партии на
|
||||
устройстве — тот же вердикт, что и на сервере, если этот словарь доступен, иначе показывается
|
||||
*недоступно офлайн*, — а жалоба и внешняя ссылка, которым нужна сеть, скрываются. Подсказки управляются настройками
|
||||
чтобы его посмотреть. Подсказки управляются настройками
|
||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||
@@ -331,16 +327,6 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
||||
вас не прерывают из-за короткой икоты.
|
||||
|
||||
**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
|
||||
Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
|
||||
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
|
||||
потеряна*, и игровая область **замораживается**: рэк и ходовые кнопки отключаются, а «сдаться»,
|
||||
«в друзья»/«заблокировать» и вход в чат/словарь скрываются; начатый ход остаётся на доске черновиком.
|
||||
Ничего не отправляется;
|
||||
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
|
||||
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
|
||||
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
|
||||
|
||||
### Актуальная версия
|
||||
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
||||
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
||||
|
||||
@@ -54,10 +54,7 @@ already fill the 108 dp canvas (foreground inside the 61 % safe zone, background
|
||||
full-bleed), so they must be placed with **no inset**, and we add the **monochrome**
|
||||
themed layer, neither of which `capacitor-assets` does. Per density mdpi→xxxhdpi (+ldpi):
|
||||
|
||||
- `mipmap-*/ic_launcher.png` — legacy square (< API 26): the **full-bleed master** (large
|
||||
mark), so old square launchers do not shrink it into the safe zone
|
||||
- `mipmap-*/ic_launcher_round.png` — legacy round (< API 26): the safe-zone **composite**,
|
||||
circle-clipped (a round mask would clip the master's corner ✻)
|
||||
- `mipmap-*/ic_launcher.png` / `ic_launcher_round.png` — legacy square / round (composite)
|
||||
- `mipmap-*/ic_launcher_foreground.png` + `ic_launcher_background.png` — adaptive layers
|
||||
- `mipmap-*/ic_launcher_monochrome.png` — themed layer
|
||||
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` — **no `<inset>`**, with
|
||||
|
||||
@@ -123,13 +123,12 @@ So Android is **not** the full-bleed master — it is built as two layers:
|
||||
and square** (no rounded corners; the OS supplies the shape). It fills the whole 108 dp
|
||||
layer, so the sacrificial outer ring is just more wood.
|
||||
- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
|
||||
the mask. The whole mark is the master's optical placement (nudged right 8 % / down 3 %)
|
||||
scaled **0.75 about the icon centre** — **25 % smaller**, its «Э» ↔ ✻ arrangement
|
||||
unchanged — so the ✻ that used to graze the round mask now sits well inside the **61 %
|
||||
safe zone** with margin to spare:
|
||||
- «Э»: box height **40.4 %**, box center **51.8 % / 49.6 %**.
|
||||
- ✻: outer diameter **9.9 %** (smaller than the master's), center **69.7 % / 67.0 %**,
|
||||
the mask:
|
||||
- «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**.
|
||||
- ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**,
|
||||
tucked closer to the letter.
|
||||
- The mark is centred, then nudged **right 8 % / down 3 %** so it reads optically
|
||||
centred under the round mask, and the whole group fits within the **61 % safe zone**.
|
||||
|
||||
Everything else (palette, grain, gradients, star construction) is identical to the master.
|
||||
|
||||
|
||||
@@ -57,21 +57,6 @@ for outside the store's own cash desk. Chips funded inside VK (Votes) may only b
|
||||
the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside
|
||||
the stores. See §4.
|
||||
|
||||
**Multi-shop direct rail (D42).** The `direct` rail routes to one Robokassa **merchant shop per
|
||||
channel** — `web` and `android` (RuStore), `ios` later — chosen by the trusted `X-Platform` subtype;
|
||||
every shop credits the one `direct` wallet (no per-channel wallet). This is merchant-account
|
||||
separation for accounting / receipts only: the order records its `shop` (shown in the admin report),
|
||||
and each shop's Result callback is verified by its own Password2 at `/pay/robokassa/result/<channel>`.
|
||||
Standalone apps (Android/iOS) sign in by email only, so a direct purchase always has the D36 email
|
||||
anchor (D43). Fiscalization (54-ФЗ via the Robokassa cabinet under one ИП) is a single source
|
||||
regardless of the number of shops (D41).
|
||||
|
||||
**Payment availability kill switch (D45/D46).** An operator can disable purchases on a rail/channel
|
||||
(`direct:web` / `direct:android` / `vk` / `telegram`) or for one account, live from `/_gm`, and the
|
||||
user sees a localized reason on their next attempt (`payment_unavailable`, carried on the additive
|
||||
`ExecuteResponse.message`). **Fail-open:** a rail with no status row is enabled. A per-account
|
||||
`allow` override bypasses only this operational switch, never the security gates (D46).
|
||||
|
||||
## 3. Three operations, kept distinct
|
||||
|
||||
Do not conflate these — they use different keys:
|
||||
|
||||
@@ -201,9 +201,7 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
|
||||
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
|
||||
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
|
||||
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге. (Ставка
|
||||
rewarded + дневной/часовой потолки — строка общего конфига `payments.config`, правится в
|
||||
секции «Rewarded ads» на странице каталога админки; это не продаваемый продукт-атом.)
|
||||
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге.
|
||||
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
|
||||
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
|
||||
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
|
||||
@@ -234,53 +232,11 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
- **D40. Финансовый отчёт per-user в `/_gm`** — балансы сегментов, платежи, траты,
|
||||
гранты, возвраты, полная история — расширение существующей карточки
|
||||
(`UserDetailView`, `handlers_admin_console.go:343`). Плюс экспорт журнала (D27).
|
||||
- **D41. Налоги/чеки — авто через провайдера (ревизия: владелец переходит на ИП).**
|
||||
**Robokassa** (direct) — фискальный чек по **54-ФЗ** через облачную кассу Robokassa
|
||||
(провайдер как фискальный агент): чек формирует касса, не банк (банковский слип об оплате —
|
||||
отдельный документ, не фискальный чек). Настраивается владельцем в ЛКК Robokassa
|
||||
(касса + ОФД + СНО). Код — опционально: слать детализированный `Receipt` + `Email`
|
||||
покупателя (берём подтверждённый email-якорь D36) → чек с точным названием пакета и ставкой
|
||||
по СНО; иначе — обобщённый дефолт-чек из кабинета. `Receipt` входит в подпись
|
||||
(`MerchantLogin:OutSum:InvId:Receipt:Пароль#1`, URL-кодируется), одна позиция влезает в
|
||||
текущий GET-redirect (POST-форма — фолбэк на длину URL). Источник чеков один — ИП/касса,
|
||||
независимо от числа магазинов Robokassa. **VK** — процессит Голоса через налоговую сам.
|
||||
**TG Stars** — вне рублёвого фискального контура (принимаем, чек не формируем). ОРД по
|
||||
VK-рекламе — на стороне VK. *(Не юрконсультация — схему по 54-ФЗ/СНО владелец сверяет с
|
||||
бухгалтером.)*
|
||||
- **D42. Direct-рельс — несколько магазинов Robokassa = маршрутизация merchant-аккаунтов по
|
||||
каналу при едином кошельке.** Кошелёк `direct` остаётся один. Отдельные магазины Robokassa
|
||||
(web, android; ios — позже) различаются только кредами / Return-URL / учётом и **все
|
||||
зачисляют в сегмент `direct`**. Модель кошельков, стенка трат и origin бенефитов не меняются.
|
||||
Магазин выбирается по трастовому сигналу канала (`X-Platform` вида `<kind>/<subtype>`:
|
||||
`direct/web`, `direct/android`), а не по подделываемому клиентскому полю; неизвестный подтип
|
||||
→ магазин `web` (безопасный дефолт). Зачисление от выбора магазина не зависит (всегда
|
||||
`direct`), поэтому ошибка маршрутизации влияет максимум на учёт, не на деньги.
|
||||
- **D43. Standalone-приложения (Android, iOS) — вход только по email; покупка требует
|
||||
email-якорь.** В нативной сборке доступны только guest + email: VK ID-логин (full-page
|
||||
redirect на `id.vk.com`) не возвращается в Capacitor, TG Login Widget в WebView ненадёжен —
|
||||
это уже действующая реальность сборки, не новое ограничение. Direct-покупка требует
|
||||
подтверждённого email-якоря (D36) — он же адрес фискального чека (D41); гость не покупает.
|
||||
Отдельный сегмент `apple` НЕ заводим: iOS-standalone — тот же `direct`-контекст, внешний гейт
|
||||
(Robokassa) фондирует единый `direct`. Сегмент `apple` со стенкой (по образцу vk/tg)
|
||||
понадобился бы только при Apple IAP (StoreKit) — отложено до решения по iOS; и identity-kind
|
||||
`apple→direct`, и отдельный сегмент — аддитивны, без риска, делаются на месте.
|
||||
- **D44. Канал платежа хранится на заказе для раздельного учёта/отчёта.** Заказ получает поле
|
||||
`shop` (web/android/…) — аддитивная колонка. Используется в финансовом отчёте (D40) для
|
||||
разбивки «из какого магазина/канала платёж». На зачисление и на сегмент кошелька не влияет
|
||||
(всегда `direct`, D42).
|
||||
- **D45. Рубильник платежей per-rail + локализованное сообщение.** Оператор выключает покупки на
|
||||
рельсе/канале (`direct:web`/`direct:android`/`vk`/`telegram`) живьём из `/_gm` (таблица
|
||||
`payments.rail_status`, редактируется на странице каталога). **Fail-open:** нет строки → рельс
|
||||
включён (случайно не убить платежи). Выключенный рельс на попытке покупки возвращает
|
||||
`payment_unavailable` + сообщение админа на языке юзера (RU/EN; пусто → встроенное «временно
|
||||
недоступно»). Сообщение едет клиенту через аддитивное `ExecuteResponse.message` (envelope-слой,
|
||||
frozen-contract-safe). Гейт ортогонален security-гейтам.
|
||||
- **D46. Per-user override покупок (allow/deny/default).** На карточке юзера (`/_gm`) —
|
||||
переопределение на аккаунт: `allow` (всегда разрешить), `deny` (всегда запретить), `default` (по
|
||||
рельс-рубильнику). Хранится в `payments.account_payment_override` строкой только для не-default
|
||||
(нет строки = default; снять = удалить строку). **`allow` обходит ТОЛЬКО ops-рубильник, НЕ
|
||||
security-гейты** (D36 email-якорь, VK-iOS-фриз, trusted-платформа, min-client-version) — те
|
||||
проверяются отдельно в CreateOrder.
|
||||
- **D41. Налоги/чеки — авто через провайдера.** **Robokassa** (direct) — авточек НПД
|
||||
(режим самозанятого). **VK** — сам процессит Голоса через налоговую (владельцу делать
|
||||
нечего). **TG Stars** — налоговой стороны нет (для РФ-самозанятого невыводимы легально
|
||||
= не доход по НПД; принимаем, чек не формируем). ОРД по VK-рекламе — на стороне VK.
|
||||
*(Не юридическая консультация — точную схему НПД владелец сверяет с налоговой стороной.)*
|
||||
|
||||
## Заметки к оформлению документов
|
||||
|
||||
@@ -296,17 +252,10 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
|
||||
feedback-память `prefer-interview-mode` после plan mode.
|
||||
|
||||
## Все развилки закрыты (D1-D46)
|
||||
## Все развилки закрыты (D1-D41)
|
||||
|
||||
Интервью завершено. Дальше — оформление документов и реализация по релизам.
|
||||
|
||||
**Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на
|
||||
ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит
|
||||
`direct`-рельса Robokassa на магазины по каналу (web/android; ios позже) при едином кошельке
|
||||
`direct` и входе email-only в standalone-приложениях (этап E10 в `PLAN.md`). Плюс D45-D46 —
|
||||
рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация
|
||||
(B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца).
|
||||
|
||||
## План внедрения (черновик PLAN.md — «слоями»)
|
||||
|
||||
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
|
||||
|
||||
@@ -57,21 +57,6 @@
|
||||
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
|
||||
сторов. См. §4.
|
||||
|
||||
**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин
|
||||
Robokassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу
|
||||
`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет).
|
||||
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в
|
||||
админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по
|
||||
`/pay/robokassa/result/<channel>`. В standalone-приложениях (Android/iOS) вход только по email, поэтому
|
||||
у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под
|
||||
одним ИП) — один источник независимо от числа магазинов (D41).
|
||||
|
||||
**Рубильник платежей (D45/D46).** Оператор выключает покупки на рельсе/канале (`direct:web` /
|
||||
`direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей
|
||||
попытке видит локализованную причину (`payment_unavailable`, едет на аддитивном
|
||||
`ExecuteResponse.message`). **Fail-open:** рельс без строки статуса — включён. Per-account `allow`
|
||||
обходит только этот ops-рубильник, не security-гейты (D46).
|
||||
|
||||
## 3. Три операции, которые нельзя путать
|
||||
|
||||
Не смешивать — у них разные ключи:
|
||||
|
||||
@@ -53,7 +53,7 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
|
||||
|
||||
A one-time **coachmark overlay** introduces the interface to a new player. Like the splash it is an
|
||||
**App-level overlay**; it runs two independent series chosen by the route — **lobby** (⚙️ settings →
|
||||
✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → ✨ hints → 🔀 shuffle →
|
||||
✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → 🛟 hints → 🔀 shuffle →
|
||||
rack). It draws **one bubble at a time** over a light scrim (`rgba(0,0,0,0.32)`); the whole layer
|
||||
captures pointer events, so a **tap anywhere advances** and the UI underneath stays inert. After the
|
||||
last hint the series is recorded done (`session.ts` `onboarding` key) and the overlay removes itself;
|
||||
@@ -284,7 +284,7 @@ e2e and the screenshots.
|
||||
opens a dialog — pick tiles to **Exchange N**, or pick
|
||||
none to **Pass without exchanging**; pass is always available on your turn, exchange only
|
||||
while the bag still holds a full rack, below which the tiles disable and only the pass
|
||||
remains), ✨ Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
|
||||
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
|
||||
no confirm), which
|
||||
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
|
||||
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
|
||||
|
||||
@@ -312,12 +312,11 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces
|
||||
}
|
||||
|
||||
// GuestAuth provisions a guest account and mints a session, seeding its time zone
|
||||
// from browserTz (the client's detected "±HH:MM" UTC offset) and its interface
|
||||
// language from language (the client's detected locale; best-effort).
|
||||
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype, language string) (SessionResp, error) {
|
||||
// from browserTz (the client's detected "±HH:MM" UTC offset).
|
||||
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype string) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
|
||||
map[string]string{"browser_tz": browserTz, "subtype": subtype, "language": language}, &out)
|
||||
map[string]string{"browser_tz": browserTz, "subtype": subtype}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -499,15 +498,10 @@ type robokassaResultResp struct {
|
||||
|
||||
// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the
|
||||
// single writer, which verifies the signature and credits) and returns the body to echo to
|
||||
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields; channel selects the
|
||||
// per-shop signature verifier (empty → the web shop).
|
||||
func (c *Client) RobokassaResult(ctx context.Context, channel string, params map[string]string) (string, error) {
|
||||
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields.
|
||||
func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) {
|
||||
var out robokassaResultResp
|
||||
path := "/api/v1/internal/payments/robokassa/result"
|
||||
if channel != "" {
|
||||
path += "?channel=" + url.QueryEscape(channel)
|
||||
}
|
||||
err := c.do(ctx, http.MethodPost, path, "", "", params, &out)
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out)
|
||||
return out.Response, err
|
||||
}
|
||||
|
||||
|
||||
@@ -139,26 +139,6 @@ func platformFromContext(ctx context.Context) string {
|
||||
return p
|
||||
}
|
||||
|
||||
// clientIPCtxKey types the request-context slot the originating client IP rides in.
|
||||
type clientIPCtxKey struct{}
|
||||
|
||||
// WithClientIP returns a copy of ctx carrying the originating client IP that do injects as
|
||||
// X-Forwarded-For on every downstream backend request — so the backend records the real caller
|
||||
// (the account's last-login IP, chat/feedback moderation, the access log) rather than the gateway's
|
||||
// own connection. An empty IP leaves ctx unchanged, so no header is sent.
|
||||
func WithClientIP(ctx context.Context, ip string) context.Context {
|
||||
if ip == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, clientIPCtxKey{}, ip)
|
||||
}
|
||||
|
||||
// clientIPFromContext returns the client IP stored by WithClientIP, or an empty string when none.
|
||||
func clientIPFromContext(ctx context.Context) string {
|
||||
ip, _ := ctx.Value(clientIPCtxKey{}).(string)
|
||||
return ip
|
||||
}
|
||||
|
||||
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
||||
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
||||
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
||||
@@ -180,13 +160,6 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
|
||||
if userID != "" {
|
||||
req.Header.Set("X-User-ID", userID)
|
||||
}
|
||||
// The client IP rides X-Forwarded-For so the backend records the real caller. It comes from the
|
||||
// explicit param (chat/feedback) or, for every other call, the request context (WithClientIP, set
|
||||
// once per request in the Connect edge) — so the backend never falls back to the gateway's own
|
||||
// connection address.
|
||||
if clientIP == "" {
|
||||
clientIP = clientIPFromContext(ctx)
|
||||
}
|
||||
if clientIP != "" {
|
||||
req.Header.Set("X-Forwarded-For", clientIP)
|
||||
}
|
||||
|
||||
@@ -122,44 +122,3 @@ func TestXPlatformInjection(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestXForwardedForInjection verifies the client IP carried on the context (WithClientIP) rides every
|
||||
// backend call as X-Forwarded-For — not just chat/feedback, which pass it explicitly — so the backend
|
||||
// records the real caller (e.g. the account's last-login IP on the profile fetch) rather than the
|
||||
// gateway's own connection. Absent when no client IP is set.
|
||||
func TestXForwardedForInjection(t *testing.T) {
|
||||
var gotXFF string
|
||||
var hadHeader bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotXFF = r.Header.Get("X-Forwarded-For")
|
||||
_, hadHeader = r.Header["X-Forwarded-For"]
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("backendclient: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
t.Run("client IP on ctx rides a non-chat call", func(t *testing.T) {
|
||||
ctx := backendclient.WithClientIP(context.Background(), "203.0.113.7")
|
||||
if _, err := c.Profile(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Profile: %v", err)
|
||||
}
|
||||
if gotXFF != "203.0.113.7" {
|
||||
t.Fatalf("X-Forwarded-For = %q, want 203.0.113.7", gotXFF)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no client IP omits the header", func(t *testing.T) {
|
||||
hadHeader = true
|
||||
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
|
||||
t.Fatalf("Profile: %v", err)
|
||||
}
|
||||
if hadHeader {
|
||||
t.Fatal("X-Forwarded-For must be absent when no client IP is set")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package connectsrv
|
||||
|
||||
import "net/http"
|
||||
|
||||
// nativeWebViewOrigins are the browser Origins of the packaged native apps. Capacitor serves the
|
||||
// bundled SPA from a localhost scheme, so its Connect calls to the gateway are cross-origin; the web
|
||||
// app is same-origin and needs no entry. Requests carry Authorization, so the allowlist reflects the
|
||||
// exact origin rather than "*".
|
||||
var nativeWebViewOrigins = map[string]bool{
|
||||
"https://localhost": true, // Capacitor Android (default https scheme)
|
||||
"http://localhost": true, // Capacitor with the http scheme
|
||||
"capacitor://localhost": true, // Capacitor iOS default scheme
|
||||
}
|
||||
|
||||
// withNativeCORS answers the CORS preflight and adds the CORS response headers for the packaged
|
||||
// native apps' cross-origin calls to the Connect edge. Without it the WebView blocks every RPC on the
|
||||
// preflight (the gateway otherwise returns 405 with no Access-Control-Allow-Origin), so a native
|
||||
// build can never reach the gateway and stays stuck offline. Same-origin (web) and any non-native
|
||||
// origin are untouched.
|
||||
func withNativeCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if nativeWebViewOrigins[origin] {
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Add("Vary", "Origin")
|
||||
h.Set("Access-Control-Allow-Credentials", "true")
|
||||
// The client interceptor reads the soft-tier update signal off the response.
|
||||
h.Set("Access-Control-Expose-Headers", "X-Update-Recommended")
|
||||
if r.Method == http.MethodOptions {
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
// Reflect the requested headers (the origin is already allowlisted): the Connect client
|
||||
// sends Connect-Protocol-Version / Connect-Timeout-Ms plus X-Client-Version and
|
||||
// Authorization, and the exact set varies by call.
|
||||
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
|
||||
h.Set("Access-Control-Allow-Headers", reqHeaders)
|
||||
} else {
|
||||
h.Set("Access-Control-Allow-Headers", "Content-Type, Connect-Protocol-Version, X-Client-Version, Authorization")
|
||||
}
|
||||
h.Set("Access-Control-Max-Age", "86400")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package connectsrv
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWithNativeCORS(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
h := withNativeCORS(next)
|
||||
|
||||
// Native-origin preflight → 204 with the CORS headers, reflecting the requested headers, without
|
||||
// reaching the inner handler.
|
||||
req := httptest.NewRequest(http.MethodOptions, "/scrabble.edge.v1.Gateway/Execute", nil)
|
||||
req.Header.Set("Origin", "https://localhost")
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
req.Header.Set("Access-Control-Request-Headers", "content-type,connect-protocol-version,x-client-version")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("preflight status = %d, want 204", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://localhost" {
|
||||
t.Errorf("Allow-Origin = %q, want https://localhost", got)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "content-type,connect-protocol-version,x-client-version" {
|
||||
t.Errorf("Allow-Headers = %q, want the reflected set", got)
|
||||
}
|
||||
|
||||
// Native-origin POST → the CORS headers are set and the inner handler runs.
|
||||
req = httptest.NewRequest(http.MethodPost, "/scrabble.edge.v1.Gateway/Execute", nil)
|
||||
req.Header.Set("Origin", "capacitor://localhost")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST status = %d, want 200 (passed through)", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||
t.Errorf("POST Allow-Origin = %q, want capacitor://localhost", got)
|
||||
}
|
||||
|
||||
// A non-native origin gets no CORS headers and still passes through (web is same-origin anyway).
|
||||
req = httptest.NewRequest(http.MethodPost, "/scrabble.edge.v1.Gateway/Execute", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("non-native POST status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("non-native Allow-Origin = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package connectsrv
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRobokassaChannel(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/pay/robokassa/result": "", // legacy bare path → the backend defaults to the web shop
|
||||
"/pay/robokassa/result/": "", // trailing slash, no channel → the web default
|
||||
"/pay/robokassa/result/web": "web",
|
||||
"/pay/robokassa/result/android": "android",
|
||||
}
|
||||
for path, want := range cases {
|
||||
if got := robokassaChannel(path); got != want {
|
||||
t.Errorf("robokassaChannel(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,11 +274,8 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
mux.Handle("/dict/", s.dictBytesHandler())
|
||||
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
|
||||
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. The
|
||||
// per-shop callback rides a channel suffix (/pay/robokassa/result/<channel>); the bare path is
|
||||
// the legacy web shop. Caddy's /pay/* matcher already forwards both, so no Caddyfile change.
|
||||
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects.
|
||||
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
|
||||
mux.Handle("/pay/robokassa/result/", s.robokassaResultHandler())
|
||||
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
|
||||
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
|
||||
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
|
||||
@@ -302,7 +299,7 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// honeypot hit is turned away before the body cap and the mux. Every request
|
||||
// body on the public listener is then capped (the admin proxy POSTs included);
|
||||
// the h2c server carries explicit stream/idle sizing.
|
||||
return h2c.NewHandler(s.abuseGuard(withNativeCORS(maxBodyHandler(s.maxBodyBytes, mux))), &http2.Server{
|
||||
return h2c.NewHandler(s.abuseGuard(maxBodyHandler(s.maxBodyBytes, mux)), &http2.Server{
|
||||
MaxConcurrentStreams: h2cMaxConcurrentStreams,
|
||||
IdleTimeout: h2cIdleTimeout,
|
||||
})
|
||||
@@ -421,10 +418,6 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
||||
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
|
||||
}
|
||||
clientIP := peerIP(req.Peer().Addr, req.Header())
|
||||
// Carry the client IP on the context so the backend client injects it as X-Forwarded-For on every
|
||||
// downstream REST call for this request — the account's last-login IP, chat/feedback moderation and
|
||||
// the backend access log, not only the chat/feedback calls that pass it explicitly.
|
||||
ctx = backendclient.WithClientIP(ctx, clientIP)
|
||||
|
||||
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
||||
if op.Auth {
|
||||
@@ -472,7 +465,6 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
||||
return connect.NewResponse(&edgev1.ExecuteResponse{
|
||||
RequestId: req.Msg.GetRequestId(),
|
||||
ResultCode: code,
|
||||
Message: transcode.DomainMessage(err),
|
||||
}), nil
|
||||
}
|
||||
s.log.Error("execute failed", zap.String("message_type", msgType), zap.Error(err))
|
||||
@@ -641,21 +633,6 @@ func (s *Server) exportDownloadHandler() http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// robokassaResultPrefix is the channel-suffixed Result path; the segment after it names the
|
||||
// per-shop channel (matches the direct-rail X-Platform subtype: "web", "android").
|
||||
const robokassaResultPrefix = "/pay/robokassa/result/"
|
||||
|
||||
// robokassaChannel extracts the shop channel from a Result callback path: the segment after
|
||||
// robokassaResultPrefix, or "" for the legacy bare /pay/robokassa/result (the backend then defaults
|
||||
// to the web shop). The backend selects the per-shop signature verifier from it.
|
||||
func robokassaChannel(path string) string {
|
||||
rest := strings.TrimPrefix(path, robokassaResultPrefix)
|
||||
if rest == path {
|
||||
return ""
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single
|
||||
// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's
|
||||
// "OK<InvId>" to Robokassa on success; any error tells Robokassa the notification was not accepted,
|
||||
@@ -680,7 +657,7 @@ func (s *Server) robokassaResultHandler() http.Handler {
|
||||
for k := range r.Form {
|
||||
params[k] = r.Form.Get(k)
|
||||
}
|
||||
resp, err := s.backend.RobokassaResult(r.Context(), robokassaChannel(r.URL.Path), params)
|
||||
resp, err := s.backend.RobokassaResult(r.Context(), params)
|
||||
if err != nil {
|
||||
s.log.Warn("robokassa result proxy failed", zap.Error(err))
|
||||
http.Error(w, "not accepted", http.StatusBadGateway)
|
||||
|
||||
@@ -189,17 +189,6 @@ func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||
return op, ok
|
||||
}
|
||||
|
||||
// DomainMessage returns the human-readable, already-localized reason a backend domain error carries
|
||||
// for the user (e.g. an operator's payment-unavailable explanation), or "" when it carries none. It
|
||||
// rides the Execute envelope's message field alongside the result code.
|
||||
func DomainMessage(err error) string {
|
||||
var apiErr *backendclient.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DomainCode maps an error to a stable result code to surface in the Execute
|
||||
// envelope, reporting false for an unexpected error the caller should treat as a
|
||||
// transport-level internal failure.
|
||||
@@ -270,14 +259,13 @@ func authGuestHandler(backend *backendclient.Client) Handler {
|
||||
// The guest bootstrap historically carried no payload; the detected zone is
|
||||
// optional, so an absent or empty one simply yields no time-zone seed (rather
|
||||
// than panicking in GetRootAs* on a zero-length buffer).
|
||||
var browserTz, subtype, locale string
|
||||
var browserTz, subtype string
|
||||
if len(req.Payload) > 0 {
|
||||
g := fb.GetRootAsGuestLoginRequest(req.Payload, 0)
|
||||
browserTz = string(g.BrowserTz())
|
||||
subtype = string(g.Subtype())
|
||||
locale = string(g.Locale())
|
||||
}
|
||||
sess, err := backend.GuestAuth(ctx, browserTz, subtype, locale)
|
||||
sess, err := backend.GuestAuth(ctx, browserTz, subtype)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -98,10 +98,6 @@ type ExecuteResponse struct {
|
||||
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
|
||||
ResultCode string `protobuf:"bytes,2,opt,name=result_code,json=resultCode,proto3" json:"result_code,omitempty"`
|
||||
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
|
||||
// message is an optional, already-localized human-readable reason a domain outcome may carry for
|
||||
// the user (e.g. a payment-unavailable explanation set by an operator). Additive and
|
||||
// frozen-contract-safe; empty for the common case where result_code alone suffices.
|
||||
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -157,13 +153,6 @@ func (x *ExecuteResponse) GetPayload() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ExecuteResponse) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SubscribeRequest opens the live stream. It is empty: the session is taken from
|
||||
// the Authorization header.
|
||||
type SubscribeRequest struct {
|
||||
@@ -273,14 +262,13 @@ const file_edge_v1_edge_proto_rawDesc = "" +
|
||||
"\fmessage_type\x18\x01 \x01(\tR\vmessageType\x12\x18\n" +
|
||||
"\apayload\x18\x02 \x01(\fR\apayload\x12\x1d\n" +
|
||||
"\n" +
|
||||
"request_id\x18\x03 \x01(\tR\trequestId\"\x85\x01\n" +
|
||||
"request_id\x18\x03 \x01(\tR\trequestId\"k\n" +
|
||||
"\x0fExecuteResponse\x12\x1d\n" +
|
||||
"\n" +
|
||||
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n" +
|
||||
"\vresult_code\x18\x02 \x01(\tR\n" +
|
||||
"resultCode\x12\x18\n" +
|
||||
"\apayload\x18\x03 \x01(\fR\apayload\x12\x18\n" +
|
||||
"\amessage\x18\x04 \x01(\tR\amessage\"\x12\n" +
|
||||
"\apayload\x18\x03 \x01(\fR\apayload\"\x12\n" +
|
||||
"\x10SubscribeRequest\"P\n" +
|
||||
"\x05Event\x12\x12\n" +
|
||||
"\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" +
|
||||
|
||||
@@ -35,10 +35,6 @@ message ExecuteResponse {
|
||||
string request_id = 1;
|
||||
string result_code = 2;
|
||||
bytes payload = 3;
|
||||
// message is an optional, already-localized human-readable reason a domain outcome may carry for
|
||||
// the user (e.g. a payment-unavailable explanation set by an operator). Additive and
|
||||
// frozen-contract-safe; empty for the common case where result_code alone suffices.
|
||||
string message = 4;
|
||||
}
|
||||
|
||||
// SubscribeRequest opens the live stream. It is empty: the session is taken from
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestExecutorChatGateInvalidExternalID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecutorCreateInvoice(t *testing.T) {
|
||||
sender := &fakeSender{invoiceLink: "https://telegram.me/$abc"}
|
||||
sender := &fakeSender{invoiceLink: "https://t.me/$abc"}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
|
||||
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
|
||||
@@ -178,7 +178,7 @@ func TestExecutorCreateInvoice(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
if !delivered || result != "https://telegram.me/$abc" {
|
||||
if !delivered || result != "https://t.me/$abc" {
|
||||
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
|
||||
}
|
||||
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
|
||||
|
||||
@@ -66,7 +66,7 @@ type BotConfig struct {
|
||||
// bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs).
|
||||
BotUsername string
|
||||
// BotLinkURL is the main bot's Mini App direct link — the same value the UI builds
|
||||
// share links from (VITE_TELEGRAM_LINK), e.g. https://telegram.me/<bot>/<app>. The promo
|
||||
// share links from (VITE_TELEGRAM_LINK), e.g. https://t.me/<bot>/<app>. The promo
|
||||
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
|
||||
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
||||
BotLinkURL string
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
|
||||
t.Setenv("TELEGRAM_CHAT_ID", "-100222")
|
||||
t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token")
|
||||
t.Setenv("TELEGRAM_BOT_USERNAME", "@ScrabbleBot")
|
||||
t.Setenv("TELEGRAM_BOT_LINK", "https://telegram.me/ScrabbleBot/app")
|
||||
t.Setenv("TELEGRAM_BOT_LINK", "https://t.me/ScrabbleBot/app")
|
||||
c, err := LoadBot()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBot: %v", err)
|
||||
@@ -126,7 +126,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
|
||||
if c.BotUsername != "ScrabbleBot" {
|
||||
t.Errorf("BotUsername = %q, want the leading @ stripped", c.BotUsername)
|
||||
}
|
||||
if c.BotLinkURL != "https://telegram.me/ScrabbleBot/app" {
|
||||
if c.BotLinkURL != "https://t.me/ScrabbleBot/app" {
|
||||
t.Errorf("BotLinkURL = %q", c.BotLinkURL)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func TestPromoTextLocalization(t *testing.T) {
|
||||
const url = "https://telegram.me/bot/app?startapp=verudit_ru-scrabble_en"
|
||||
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en"
|
||||
// The @username is rendered as a clickable deep link (the same target as the button),
|
||||
// not a plain mention, so tapping it opens the seeded Mini App.
|
||||
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
|
||||
@@ -41,17 +41,17 @@ func TestPromoTextLocalization(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLaunchURLAppendsStartapp(t *testing.T) {
|
||||
b := &Bot{linkURL: "https://telegram.me/bot/app"}
|
||||
if got := b.launchURL(""); got != "https://telegram.me/bot/app" {
|
||||
b := &Bot{linkURL: "https://t.me/bot/app"}
|
||||
if got := b.launchURL(""); got != "https://t.me/bot/app" {
|
||||
t.Errorf("empty payload = %q, want the base link unchanged", got)
|
||||
}
|
||||
if got := b.launchURL("g123"); got != "https://telegram.me/bot/app?startapp=g123" {
|
||||
if got := b.launchURL("g123"); got != "https://t.me/bot/app?startapp=g123" {
|
||||
t.Errorf("launchURL = %q, want startapp=g123 appended", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchMarkupIsURLButton(t *testing.T) {
|
||||
b := &Bot{linkURL: "https://telegram.me/bot/app"}
|
||||
b := &Bot{linkURL: "https://t.me/bot/app"}
|
||||
btn := b.launchMarkup("Play", "f99").InlineKeyboard[0][0]
|
||||
if btn.WebApp != nil {
|
||||
t.Error("the promo button must not be a web_app button (it would sign initData with the promo token, which the main bot rejects)")
|
||||
@@ -84,7 +84,7 @@ func TestHandleStartReplies(t *testing.T) {
|
||||
api := &fakeAPI{}
|
||||
srv := httptest.NewServer(api)
|
||||
t.Cleanup(srv.Close)
|
||||
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://telegram.me/bot/app"}, zap.NewNop())
|
||||
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://t.me/bot/app"}, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestHandleStartReplies(t *testing.T) {
|
||||
if api.chatID != "42" {
|
||||
t.Errorf("chat_id = %q, want 42", api.chatID)
|
||||
}
|
||||
if !strings.Contains(api.text, `<a href="https://telegram.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
|
||||
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
|
||||
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
|
||||
}
|
||||
if strings.Contains(api.replyMarkup, "web_app") {
|
||||
@@ -113,7 +113,7 @@ func TestHandleStartIgnoresGroup(t *testing.T) {
|
||||
api := &fakeAPI{}
|
||||
srv := httptest.NewServer(api)
|
||||
t.Cleanup(srv.Close)
|
||||
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://telegram.me/b/a"}, zap.NewNop())
|
||||
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://t.me/b/a"}, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
|
||||
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID`
|
||||
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
|
||||
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
||||
friend-invite Mini App link for the single bot (full URL `https://telegram.me/<bot>/<app>`).
|
||||
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
|
||||
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
|
||||
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
|
||||
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
|
||||
|
||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.4 KiB |