Files
Chengdong ZhangandCursor 835afbe5e0 fix(api): decrement SKU stock only when remaining quantity is sufficient
Prevent oversell by conditioning stock updates and locking SKUs in primary-key order, and record the same concurrent-counter rule in the API spec and agent guide.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 17:41:06 +08:00

6.0 KiB

Rust API tech spec

Companion to ADR 0001 and ADR 0002. This document describes the current vmall-api layout and the rules new code must follow. HTTP behavior for each domain remains in openspec/specs/ (auth, catalog, cart, order, …).

Context and motivation

Handlers previously mixed Axum extracts, business rules, and sqlx. The crate is now a modular monolith so checkout, fulfillment, and identity can be shared across the three role surfaces without changing REST URLs or JSON.

Goals:

  • One crate, one process, Postgres + Redis.
  • Handler → service → repository (or store) per bounded context.
  • Stable REST contract for @vmall/shared and HTTP integration tests.

Non-goals:

  • GraphQL as the primary API (ADR 0002).
  • Hexagonal ports/adapters, a generic Repository trait, or a second web framework.
  • Changing error envelope, money representation, or RBAC roles.

Implementation considerations

  • Crate: apps/api, package vmall-api, Axum 0.8, sqlx 0.8, Redis connection manager.
  • Migrations: apps/api/migrations/, append-only; run on boot against a single PgPool shared with the server (see main.rs + state::assemble).
  • Money: i64 minor units + ISO code; money::convert_minor is a pure function, not a repository.
  • SQL: sqlx::query* / query_as with explicit binds. Prefer column lists over SELECT * on users (use USER_COLUMNS + User row vs UserPublic JSON).
  • State machines: UPDATE … WHERE status = …; zero rows → ApiError::Conflict, never a silent no-op success.
  • Concurrent counters: any column that concurrent requests decrement (today: skus.stock) follows the same “predicate or it did not happen” rule as status. SELECT … FOR UPDATE of multiple rows MUST ORDER BY the primary key so lock order is global. Decrement MUST be UPDATE … SET col = col - $qty WHERE id = $1 AND col >= $qty; rows_affected = 0Conflict (do not rely on CHECK (col >= 0) as the only signal). Restore with col = col + n, never SET col = $absolute from a stale read. Merchant overwrite (SET stock = $n on SKU upsert) is assignment, not decrement. Remaining shipment qty is not a stored counter: create serializes on orders … FOR UPDATE and checks remainder in that transaction.
  • RBAC: AuthUser::require, require_customer, require_admin, require_shop / own_shop. Cross-shop resource access is 404, not 403.

High-level request flow

Nuxt + @vmall/shared  →  Handler (Axum)
                      →  Service (use case)
                      →  Repo / cart store
                      →  Postgres | Redis
  1. Handler extracts State<AppState>, AuthUser, path/query/JSON.
  2. Handler maps HTTP-only concerns (StatusCode::CREATED) after the service returns ApiResult<Dto>.
  3. Service opens transactions when more than one write must commit together (checkout, default address, shipment create).
  4. Repository functions take &PgPool, &mut PgConnection, or &mut Transaction so a service can compose them.

Module map

Module Bounded context Typical routes
identity Auth, users, role assignment /auth/*, /admin/users
catalog Products, SKUs, categories, brands /products, /shop/products, /brands
cart Redis cart + purchasable snapshots /cart
order Checkout, pay, cancel, lists by scope /orders, /shop/orders, /admin/orders
fulfillment Shipments, delivery confirmation /shipments, /shop/shipments
billing Invoice request / issue /invoices, /shop/invoices
address Customer address book /addresses
shop Shop CRUD, profiles, /shop/profile /shops, /admin/shops
content Home banners / promos / links /content/home, /admin/content
currency Rates and convert /currencies
health Liveness / readiness /health, /ready

Each module typically contains mod.rs, handlers.rs, service.rs, and optionally repo.rs / dto.rs / store.rs. Merge routers in modules::api_router().

Shared types live in models.rs (sqlx FromRow + enums). API-facing user JSON is UserPublic (no password_hash).

Error handling

ApiError serializes as {"error":{"code","message"}}:

Variant HTTP code
NotFound 404 NOT_FOUND
BadRequest 400 BAD_REQUEST
Unauthorized 401 UNAUTHORIZED
Forbidden 403 FORBIDDEN
Conflict 409 CONFLICT
Internal 500 INTERNAL (generic message; details in logs)

From<sqlx::Error>: RowNotFound → 404; unique / check violations → 409. Domain-specific unique messages use unique_conflict(err, "email already registered").

Future-proofing

  • New capabilities get a new or existing modules/<ctx> plus OpenSpec; they do not add SQL to handlers.
  • A later read-only GraphQL surface would sit beside REST and call the same services.
  • Compile-time query_as! may replace string SQL incrementally; it is not required for new queries.

Testing approach

  • HTTP contract: apps/api/tests/*.rs via tests/common/mod.rs (spawn_app, unique slugs). These tests are the REST regression gate.
  • Service: tests/order_service.rs (and similar) call services with spawn_state() — empty cart, stock 409, split-by-shop, illegal status transitions.
  • Pure functions: money, http::pagination unit tests in-module.

Acceptance criteria

  • New write use cases live in a service; handlers do not embed sqlx except trivial reads if a service would be empty ceremony.
  • cargo test -p vmall-api stays green and repeatable against vmall_test + Redis.
  • REST paths and JSON shapes used by @vmall/shared do not change without an OpenSpec change.