Files
vmall/docs/backend-guidelines.md
T

77 lines
3.2 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
```
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`.