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.
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)returnsNUMERIC— alwaysCOALESCE(SUM(x), 0)::bigintbefore decoding intoi64. (Regression test:tests/aftersales.rsandtests/freight.rscover this.)INSERTcolumn 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 checkgreen 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
- Migration:
ALTER TABLE … ADD COLUMN(append-only file, never edit old ones once applied anywhere). - Add the field to the model struct in
models.rs. - Add the column to every SELECT/RETURNING list for that table.
- Extend INSERT binds if writable.
- 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.