docs: API architecture ADRs, tech spec, and marketing capability tracker
Persist the modular-monolith decision (ADR 0001 handler/service/repo, ADR 0002 keep REST) with the companion tech spec and the api-architecture OpenSpec capability. Replace the finished mock-migration tracker with docs/TBD-marketing.md listing the backend-less marketing domains still on fixtures (coupons, favorites, account stats, seckill, collective, integral, reviews). README and AGENTS.md point at the new docs.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# TBD — marketing capabilities with no backend (mall mock holdouts)
|
||||
|
||||
The mock→live migration finished at `replace-mock-api-wave-7` (address book, 2026-09-18).
|
||||
Every domain that has an API is live; what follows is what still renders from
|
||||
`apps/mall/mock/data.ts` because **no backend capability exists for it**. Each entry is a
|
||||
new capability (schema + routes + contract + pages), not a domain flip — pick one up by
|
||||
opening an OpenSpec change, the same way waves 1–7 did.
|
||||
|
||||
**How to use:** check a box only once the behaviour is implemented *and* verified against
|
||||
the live backend (`cargo run -p vmall-api`, `node scripts/seed-demo.mjs`), then remove the
|
||||
mock data and the page's `~/mock/data` import in the same change.
|
||||
|
||||
**Delete this file** once every box is checked, or consciously dropped and recorded. The
|
||||
"deliberately out of scope" list at the bottom does not block deleting it.
|
||||
|
||||
---
|
||||
|
||||
## Mock holdouts with a mall UI today
|
||||
|
||||
- [ ] **Coupons** — `user/coupons.vue` lists `MOCK_COUPONS`; `goods/[id].vue` shows a
|
||||
claim strip off the same fixture.
|
||||
Missing: `coupon_templates` (shop-issued: amount/threshold/window/stock), `coupons`
|
||||
(user-held, order-bound status). APIs: `GET /api/coupons` (mine),
|
||||
`POST /api/coupons/claim` (from a template), shop-admin template CRUD, and checkout
|
||||
application (select → discount minor → bind to order). Money math stays minor units.
|
||||
- [ ] **Favorites** — `user/favorites.vue` lists `MOCK_FAVORITES` (products tab +
|
||||
stores tab); `user/index.vue` derives counts from it.
|
||||
Missing: `favorites(user_id, product_id | shop_id)` with a partial unique index per
|
||||
target kind. APIs: `GET/POST/DELETE /api/favorites` (product & shop variants).
|
||||
- [ ] **Account stats** — `user/index.vue` shows `USER_STATS` (balance 128.00, points
|
||||
2680, frozen 0) and `integral.vue` reuses the points figure.
|
||||
Missing: balance/points accounts and ledgers (`money_logs` in the reference).
|
||||
MVP shape: `GET /api/me/stats` returning `{ balance_minor, points, frozen_minor }`;
|
||||
real ledgers only when a flow (recharge, refund-to-balance, points earn/spend) needs them.
|
||||
|
||||
## Marketing pages that are display-only mock
|
||||
|
||||
These exist as full pages (`seckill.vue`, `collective.vue`, `integral.vue`) linked from
|
||||
the home navigation; all three read fixtures directly.
|
||||
|
||||
- [ ] **Seckill (秒杀)** — `SECKILL_SESSIONS` + `seckillProducts()` (price override,
|
||||
sold %).
|
||||
Missing: `seckill_sessions`, `seckill_products` (activity price, isolated stock),
|
||||
`GET /api/seckill/sessions`, and checkout price resolution honouring the active session.
|
||||
- [ ] **Collective / 拼团** — `collectiveProducts()` (need/joined counts).
|
||||
Missing: `collective_activities`, `collective_groups` (open/join/expire, success on
|
||||
fill); orders bind to a group; refund/rollback policy on expiry.
|
||||
- [ ] **Integral mall / 积分商城** — `INTEGRAL_PRODUCTS` + points from `USER_STATS`.
|
||||
Missing: points ledger (earn/spend), `integral_products`, points-denominated checkout
|
||||
(`integral/orders` in the reference).
|
||||
|
||||
## Domains the reference has and this MVP does not (no UI here)
|
||||
|
||||
Recorded so the gap is explicit, not because all of them belong in scope:
|
||||
|
||||
- [ ] **Reviews / 评价** — no model; the mall presents none (review counts, the detail
|
||||
page's review tab/summary/replies were removed in wave 6 rather than kept invented).
|
||||
Missing: `order_comments` (order-item bound, rated, replyable), public read on product
|
||||
pages, shop reply, admin moderation. Writing/moderating/displaying reviews is a feature
|
||||
with its own lifecycle.
|
||||
- [ ] **Distribution / 分销**, **cashes / 提现**, **money logs** — qwshop user-center
|
||||
modules; no mock, no UI, no model here.
|
||||
- [ ] **Help center / articles** — nav links exist in the footer (`帮助中心`); no article
|
||||
model. Cheap version: static content pages; full version: admin-managed articles.
|
||||
- [ ] **OAuth login, SMS/captcha** — reference `users/oauth` + captcha plugin; here auth
|
||||
is email+password only.
|
||||
- [ ] **Freight templates / 运费模板** — shop-side shipping-fee rules; checkout currently
|
||||
charges no shipping at all.
|
||||
|
||||
## Deliberately out of scope — does not block deleting this file
|
||||
|
||||
- **The fixed-data adapter itself** (`apps/mall/mock/api.ts`, `~/mock/data`): the
|
||||
Mock API adapter spec requires it to keep serving every domain as the rollback path.
|
||||
Removing the fixtures above means pages stop *reading* them; the adapter stays.
|
||||
|
||||
## Invariants to keep when implementing any box
|
||||
|
||||
- Money is `i64` minor units + currency code; no float math anywhere.
|
||||
- New user-facing content fields are `{en, zh}` JSONB; UI copy goes through `$t()`.
|
||||
- Contract changes land only in `packages/shared` and all three frontends must still build.
|
||||
- State transitions validate preconditions (`UPDATE ... WHERE status = ...` pattern).
|
||||
- Each capability gets its own `openspec/changes/<name>/` and archives green.
|
||||
@@ -0,0 +1,51 @@
|
||||
# 0001. Modular monolith with handler / service / repository
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-09-18
|
||||
- Deciders: VMall maintainers
|
||||
- Related: [0002](0002-keep-http-rest-not-graphql.md), [tech spec](../tech-specs/rust-api.md)
|
||||
|
||||
## Context
|
||||
|
||||
`vmall-api` started as Axum route modules with SQL, validation, and HTTP mapping in the same handler. That was fine for an MVP of a few files. Checkout, stock, shipment status, and invoices then lived in 200–400 line handlers, duplicated across customer / shop / admin surfaces, with `SELECT *` leaking `password_hash` behind `skip_serializing`.
|
||||
|
||||
Rails-style MVC does not map cleanly onto Axum: there is no View layer, and “Controller” is just the handler. Full hexagonal / DDD (ports, adapters, domain events) would add compile time and indirection without a second persistence backend.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep a **single crate** (`vmall-api`) as a **modular monolith**. Split code by **bounded context** under `apps/api/src/modules/<context>/`, with three roles:
|
||||
|
||||
| Layer | Owns | Must not own |
|
||||
|-------|------|----------------|
|
||||
| Handler | Axum extracts, RBAC, HTTP status, JSON envelope | SQL, Redis, state machines |
|
||||
| Service | Use cases (checkout, default address, publish product) | `Json`, `StatusCode`, path params |
|
||||
| Repository / store | sqlx and Redis | HTTP types |
|
||||
|
||||
Simple CRUD may skip the service file and call the repository from the handler. Do **not** introduce a generic `Repository` trait unless a second backend exists.
|
||||
|
||||
Shared crate roots stay small: `error`, `auth`, `models`, `money`, `state`, `http` (pagination / query DTOs), `config`, `seed`.
|
||||
|
||||
HTTP paths, JSON field names, and `{"error":{"code","message"}}` stay unchanged so `@vmall/shared` and the three Nuxt apps do not move.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Customer, shop, and admin order lists share `order::service` with an `OrderScope`.
|
||||
- Checkout and fulfillment can be unit-tested against `AppState` without HTTP.
|
||||
- New features land in an existing module instead of growing `routes/*.rs`.
|
||||
|
||||
Negative:
|
||||
|
||||
- More files per use case; trivial list endpoints look heavier than a single handler.
|
||||
- Cross-module calls (fulfillment → order repo) must stay explicit; no hidden event bus.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep fat handlers.** Rejected: checkout and shipment transitions were already hard to reuse.
|
||||
|
||||
**Classic MVC packages (`controllers/`, `services/`, `models/`).** Rejected: splits a use case across three top-level trees; Axum has no views.
|
||||
|
||||
**Hexagonal architecture + domain events.** Rejected for current size: one Postgres, one Redis, one process.
|
||||
|
||||
**Framework switch (Loco, Actix).** Rejected: Axum 0.8 already matches the stack; a rewrite would not fix layering.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 0002. Keep HTTP REST; do not replace the API with GraphQL
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-09-18
|
||||
- Deciders: VMall maintainers
|
||||
- Related: [0001](0001-rust-api-modular-monolith.md)
|
||||
|
||||
## Context
|
||||
|
||||
The mall, shop-admin, and platform-admin apps share one typed REST client in `@vmall/shared`. Handlers already return composed DTOs (`ProductWithSkus`, `OrderView`, `CartView`). Command flows (checkout, pay, cancel, partial ship, issue invoice) are state machines with 409 conflicts and idempotent `UPDATE … WHERE status = …`.
|
||||
|
||||
A GraphQL rewrite was proposed to “modernize” the API.
|
||||
|
||||
## Decision
|
||||
|
||||
**Keep REST** on `/api/*`. Do not replace the public contract with GraphQL.
|
||||
|
||||
A **read-only GraphQL** endpoint for catalog browsing may be considered later if a third-party or mobile client needs arbitrary field sets. Write paths (checkout, stock, fulfillment, invoices) stay REST commands.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Existing OpenSpec HTTP scenarios, integration tests, and the mock adapter remain valid.
|
||||
- Role checks stay on routes (`AuthUser::require_*`), not per GraphQL field.
|
||||
- GET caching and payment/webhook-style POSTs stay straightforward.
|
||||
|
||||
Negative:
|
||||
|
||||
- Clients that want a custom nested graph still make several REST calls (already the case; DTOs cover storefront needs).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Full GraphQL (`async-graphql`) as the only API.** Rejected: would rewrite three apps, `@vmall/shared`, seed scripts, and all HTTP tests; field-level auth for three roles on one schema is harder to audit; N+1 needs DataLoaders; uploads and webhooks still want REST.
|
||||
|
||||
**JSON:API / sparse fieldsets.** Not needed while composed DTOs match the UIs.
|
||||
|
||||
**BFF per frontend.** Unnecessary while all three apps share one contract package.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
ADRs in this directory record **why** the Rust API (`apps/api`, crate `vmall-api`) is structured the way it is. They are written in English and do not replace OpenSpec capability specs (`openspec/specs/`), which describe **what** HTTP behavior clients may rely on.
|
||||
|
||||
| ID | Title | Status |
|
||||
|----|--------|--------|
|
||||
| [0001](0001-rust-api-modular-monolith.md) | Modular monolith with handler / service / repository | Accepted |
|
||||
| [0002](0002-keep-http-rest-not-graphql.md) | Keep HTTP REST; do not replace the API with GraphQL | Accepted |
|
||||
|
||||
Template (MADR-inspired): Context → Decision → Consequences → Alternatives.
|
||||
|
||||
New ADRs: next unused number, `NNNN-kebab-title.md`, Status `Proposed` until accepted.
|
||||
|
||||
Companion: [tech spec — Rust API](../tech-specs/rust-api.md), [OpenSpec — api-architecture](../../openspec/specs/api-architecture/spec.md).
|
||||
@@ -0,0 +1,104 @@
|
||||
Rust API tech spec
|
||||
==================
|
||||
|
||||
Companion to [ADR 0001](../adr/0001-rust-api-modular-monolith.md) and [ADR 0002](../adr/0002-keep-http-rest-not-graphql.md). 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](../adr/0002-keep-http-rest-not-graphql.md)).
|
||||
- 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.
|
||||
- **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.
|
||||
Reference in New Issue
Block a user