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.
116 lines
5.3 KiB
Markdown
116 lines
5.3 KiB
Markdown
# 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)
|
|
|
|
```sql
|
|
-- 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:
|
|
|
|
```sql
|
|
-- 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.
|