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:
Chengdong Zhang
2026-09-18 16:00:31 +08:00
parent c51e96ae41
commit e10cae5789
9 changed files with 341 additions and 10 deletions
+82
View File
@@ -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 17 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 200400 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.
+14
View File
@@ -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).
+104
View File
@@ -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.