Files
vmall/docs/backend-guidelines.md
T
james 9904696e76 feat: wave 2 migration (P3, P5, P7 openspec changes)
Implements, verifies, and archives the three remaining Wave 2 changes from
openspec/MIGRATION-PLAN.md.

- add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and
  one-time admin review, paginated own fund entries, idempotent per-shop
  weekly/monthly settlement statements with commission rate and one-time
  payout confirmation.
- add-merchant-onboarding (P5): personal/enterprise applications with one live
  application per user, guarded review with mandatory rejection reason, and
  transactional shop + owner provisioning returning one-time credentials;
  mall onboarding/status pages and an admin review console.
- add-membership-messaging (P7): platform member levels, append-only growth
  accrual on order completion with guarded one-way leveling, order/shipment/
  refund system messages with unread/read state and soft deletion, plus the
  mall header unread badge.

Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding,
membership and messaging modules, event hooks in order/fulfillment/aftersale,
and integration suites for each. Shared contract extended and all three
frontends updated; code indexes, domain docs, backend guidelines and the
migration tracker synced.

Verification: cargo test -p vmall-api green twice consecutively; mall, admin
and shop-admin builds pass; browser smoke on every new surface; openspec
validate --all --strict green (33 passed).

The three changes share the @vmall/shared contract, the mall mock adapter and
per-app locale/nav files, so they are committed together to keep every commit
buildable.
2026-09-25 15:25:29 +00:00

5.3 KiB

Backend Guidelines (vmall-api)

Normative layering lives in docs/tech-specs/rust-api.md and openspec/specs/api-architecture/spec.md. This file is the practical playbook with patterns proven by the existing modules.

Layering

src/modules/<ctx>/: handlers.rs (Axum extractors only) → service.rs (ApiResult<Dto>, no Json/StatusCode) → optional repo.rs (sqlx in &mut PgConnection / &mut Transaction). Simple CRUD may call repo from handlers. No generic Repository trait.

New domain checklist: migration → models.rs rows/enums → module (mod.rs/service.rs/handlers.rs) → register in modules/mod.rs → apps/api/tests/<ctx>.rs → shared contract → frontends.

Errors

ApiError envelope {"error":{"code","message"}}. NotFound 404, BadRequest 400, Forbidden 403, Conflict 409. Use unique_conflict(err, msg) to map unique violations to 409.

Concurrency and state machines (do not improvise)

-- status transition: condition on the expected prior state
UPDATE aftersales SET status = $1, updated_at = now()
 WHERE id = $2 AND status = ANY($3)          -- 0 rows → ApiError::Conflict

-- guarded decrement (stock): never SET col = col - n unconditionally
UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2

-- one-time flag (reply, reopen): guard on the empty state
UPDATE product_reviews SET reply = $2 WHERE id = $1 AND reply IS NULL

-- one-time review out of a pending state (wallet withdrawals, payouts)
UPDATE wallet_withdrawals SET status = $2, reviewed_by = $3, reviewed_at = now()
 WHERE id = $1 AND status = 'pending'          -- 0 rows → ApiError::Conflict

Idempotent generation guarded by a unique index (settlement statements): insert with ON CONFLICT (<cols>) DO NOTHING RETURNING id; a None result means a repeat or a racing request won, so re-read the existing row and return it unchanged instead of recomputing.

"At most one live row per owner" (merchant applications: one pending/approved per user) is a partial unique index — UNIQUE (user_id) WHERE status IN (...) — checked first in the service for a friendly 409 and relied on as the concurrency backstop via unique_conflict. Transactional provisioning (approval creating a shop + owner account) reads the row FOR UPDATE, provisions, then runs the guarded status flip last so any failure rolls the whole thing back.

Event side effects (membership growth, system messages) run inside the transition's transaction and are made idempotent by a partial unique index plus ON CONFLICT DO NOTHING, so a retried handler cannot double-write:

-- one message per customer, kind, and reference
INSERT INTO messages (...) VALUES (...)
ON CONFLICT (user_id, kind, reference_type, reference_id)
    WHERE reference_id IS NOT NULL DO NOTHING

A per-customer ledger total (growth) serializes on SELECT id FROM users WHERE id = $1 FOR UPDATE, then reads COALESCE(SUM(delta),0)::bigint and appends the entry carrying the new running total.

Multi-row FOR UPDATE must ORDER BY primary key. Ledger writes are append-only: change a balance only via account::service::credit/debit inside the caller's transaction, never by writing an absolute balance. A credit in a currency the customer never held: create the zero-balance row first (account::service::ensure_monetary_account).

Money and i18n

i64 minor units, BIGINT in SQL, number in TS. Currency conversion only via money::convert_minor(amount, from, to). User-facing text columns are JSONB {en, zh}; validate with the bilingual() pattern (both locales non-empty) or some_locale() (at least one — chat/message-style content).

Watch out: Postgres type traps

  • SUM(bigint) returns NUMERIC — always COALESCE(SUM(x), 0)::bigint before decoding into i64. (Regression test: tests/aftersales.rs and tests/freight.rs cover this.)
  • INSERT column count must equal value count; when a table grows, update every column-list constant (ORDER_COLS, ORDER_ITEM_COLS, …) and both INSERT lists. sqlx decodes at runtime, not compile time — cargo check green does not prove queries.

Testing

apps/api/tests/ with tests/common/mod.rs fixtures; the shared test DB is never truncated, so fixtures use unique slugs/emails and tests assert only on ids they created. spawn_state() initializes tracing (RUST_LOG works). Run cargo test -p vmall-api twice before archiving a change; list/discovery endpoints especially. Mind pipefail: cargo test | grep hides failures.

Adding a column to an existing table

  1. Migration: ALTER TABLE … ADD COLUMN (append-only file, never edit old ones once applied anywhere).
  2. Add the field to the model struct in models.rs.
  3. Add the column to every SELECT/RETURNING list for that table.
  4. Extend INSERT binds if writable.
  5. Mirror in packages/shared/src/types.ts.

Nuance: a column that only one module writes and reads may stay out of the shared models.rs row when adding the field would force every existing SELECT/RETURNING list for that table to change. Example: orders.completed_at (settlement period attribution) is written by the order completion transition and read by modules/settlement/repo.rs; models::Order does not carry it. Also remember updated_at is not a completion timestamp — refunds bump it — so period attribution needs a dedicated column.