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
+2 -2
View File
@@ -4,10 +4,10 @@
## 布局与所有权
- `apps/api` — Rust 后端(crate `vmall-api`)。迁移在 `apps/api/migrations/`(sqlx,启动时自动执行,只增不改)。代码按限界上下文放在 `src/modules/<ctx>/`handler → service → repo);HTTP 抽取器在 `src/http/`。新功能写进对应模块,不要把 SQL 堆回 handler。简单 CRUD 允许 handler 直接调 repo。Service 返回 `ApiResult<Dto>`,不依赖 `Json`/`StatusCode`。Repo 接受 `&mut PgConnection` / `&mut Transaction` 以便组合事务。不引入泛型 Repository trait。
- `apps/api` — Rust 后端(crate `vmall-api`)。迁移在 `apps/api/migrations/`(sqlx,启动时自动执行,只增不改)。分层约定见英文:`docs/adr/``docs/tech-specs/rust-api.md``openspec/specs/api-architecture/spec.md`代码按限界上下文放在 `src/modules/<ctx>/`handler → service → repo);HTTP 抽取器在 `src/http/`。新功能写进对应模块,不要把 SQL 堆回 handler。简单 CRUD 允许 handler 直接调 repo。Service 返回 `ApiResult<Dto>`,不依赖 `Json`/`StatusCode`。Repo 接受 `&mut PgConnection` / `&mut Transaction` 以便组合事务。不引入泛型 Repository trait。
- `apps/mall` / `apps/shop-admin` / `apps/admin` — 三个 Nuxt 3 应用。端口固定 3000/3001/3002。
- `packages/shared``@vmall/shared`**唯一** API 契约(`src/types.ts` + `src/api.ts`)、en/zh 语言包、共享样式 `ui.css`。前端禁止自建 API 封装;契约变更只在这里改,且三个前端都要过构建。
- `openspec/` — 规范。`specs/` 是已归档能力规范(auth, rbac, catalog, currency, cart, order, shipment, invoice, frontend-*)。
- `openspec/` — 规范。`specs/` 是已归档能力规范(auth, rbac, catalog, currency, cart, order, shipment, invoice, api-architecture, frontend-*)。
- `scripts/seed-demo.mjs` — 幂等演示数据。
## 硬性约定
+5 -7
View File
@@ -13,6 +13,8 @@ apps/
packages/
shared/ @vmall/sharedTS 类型、API client、en/zh 语言包、共享样式
openspec/ OpenSpec 规范(specs/ 为归档后的能力规范)
docs/adr/ Architecture Decision Records(英文)
docs/tech-specs/ 内部技术规格(英文)
scripts/
seed-demo.mjs 演示数据脚本
```
@@ -71,15 +73,11 @@ openspec validate --all --strict # 规范校验
## 商城的 mock 边界
`apps/mall` 通过 `apps/mall/plugins/api.ts``liveDomains` 按域选择适配器。已有 API 的域全部走真实后端:catalog、currency、content、brands、shops、auth、cart、orders、shipments、invoices。
`apps/mall` 通过 `apps/mall/plugins/api.ts``liveDomains` 按域选择适配器。已有 API 的域全部走真实后端:catalog、currency、content、brands、shops、auth、cart、orders、shipments、invoices、addresses
仍来自 `~/mock/data` 的部分是**有意保留**的展示内容,不是待办:
仍来自 `~/mock/data` 的部分都是没有后端能力的营销/账户域,逐项记录在 `docs/TBD-marketing.md`(优惠券、收藏、账户统计、秒杀、拼团、积分商城、评价等)。每一项都是一次新的能力建设,不是适配层切换。
- **收货地址**`MOCK_ADDRESSES`):`Address` 内嵌在订单里,结算直接在请求体里携带,不需要地址表
- **优惠券、收藏、账户统计**:纯展示,不参与交易。
- **seckill / collective / integral 营销页**:纯展示内容。
- **商品评价**:没有评价模型,所以商城不再展示任何评价——卡片上的评价数与详情页的评价页签、评分、回复都已移除,而不是继续展示虚构的评论者与评分。评价是独立功能,不是迁移的一部分。
- **fixed-data 适配器本身**`Mock API adapter` 规范要求它仍能服务每个域,因此它是回滚路径,删除它会破坏回滚。
- **fixed-data 适配器本身**:`Mock API adapter` 规范要求它仍能服务每个域,因此它是回滚路径,删除它会破坏回滚
## 环境变量(后端)
+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.
+1 -1
View File
@@ -4,7 +4,7 @@ context: |
VMall: B2B2C e-commerce MVP, spec-driven in phases.
Tech stack:
- Backend: Rust (axum 0.8, sqlx 0.8 + Postgres 18, redis 8, JWT, argon2) at apps/api, crate vmall-api. Money is stored as integer minor units + ISO currency code; never floats.
- Backend: Rust (axum 0.8, sqlx 0.8 + Postgres 18, redis 8, JWT, argon2) at apps/api, crate vmall-api. Modular monolith: handler → service → repository under src/modules/<context> (docs/adr/0001, docs/tech-specs/rust-api.md). HTTP REST is the public contract; GraphQL is not the primary API (docs/adr/0002). Money is stored as integer minor units + ISO currency code; never floats.
- Frontends: three Nuxt 3 apps in pnpm workspace: apps/mall (customer storefront, port 3000), apps/shop-admin (merchant console, 3001), apps/admin (platform console, 3002).
- Shared contract: packages/shared (@vmall/shared) — TS types, API client, en/zh locales, ui.css. Frontends must use it; no per-app API reimplementation.
- Dev infra: Postgres + Redis run in local docker (containers pg18, rdb8); databases vmall / vmall_test; API runs migrations on boot (sqlx migrate).
+44
View File
@@ -0,0 +1,44 @@
# api-architecture Specification
## Purpose
Internal layout of `vmall-api`: a modular monolith (handler → service → repository) over a stable HTTP REST contract. Client-visible behavior of each domain remains in the other OpenSpec capabilities.
## Requirements
### Requirement: Bounded-context modules
Domain code SHALL live under `apps/api/src/modules/<context>/` (identity, catalog, cart, order, fulfillment, billing, address, shop, content, currency, health). Shared HTTP helpers SHALL live under `apps/api/src/http/`. New features MUST NOT add sqlx to a deleted-style `routes/` tree.
#### Scenario: new use case
- **WHEN** a developer adds a write use case (for example checkout or set-default-address)
- **THEN** the SQL and transaction live in a service and/or repository, and the Axum handler only authenticates, deserializes, and maps `ApiResult` to status + JSON
#### Scenario: simple list
- **WHEN** the endpoint is a single SELECT with no extra rules
- **THEN** the handler MAY call the repository directly without a dedicated service function
### Requirement: Layer contracts
Services SHALL return `ApiResult<Dto>` and MUST NOT depend on `axum::Json` or `StatusCode`. Repositories SHALL accept `&PgPool`, `&mut PgConnection`, or `&mut Transaction` so one service can compose several writes. The crate MUST NOT introduce a generic persistence trait unless a second backend exists.
#### Scenario: checkout transaction
- **WHEN** checkout locks SKUs, inserts orders, decrements stock, and clears the cart
- **THEN** Postgres writes share one transaction in `order::service`, and Redis cart clear happens after commit
### Requirement: Stable REST envelope
The public API SHALL remain resource-oriented REST under `/api`. Errors SHALL use `{"error":{"code","message"}}`. Unique and check constraint violations SHALL map to HTTP 409 unless the handler substitutes a domain message via `unique_conflict`.
#### Scenario: duplicate email
- **WHEN** registration hits a unique email constraint
- **THEN** the client receives 409 with code `CONFLICT` and a domain message, not a 500
### Requirement: Shared use cases across surfaces
Customer, shop, and platform-admin reads of the same aggregate SHALL call one service with an explicit scope (for example `OrderScope::{User, Shop, Admin}`) instead of copying SQL per router.
#### Scenario: order list
- **WHEN** `GET /api/orders`, `GET /api/shop/orders`, and `GET /api/admin/orders` run
- **THEN** they share `order::service::list` and differ only by auth and scope
### Requirement: Tests
HTTP integration tests under `apps/api/tests/` SHALL remain the contract suite. State-machine use cases (checkout stock, illegal pay/cancel) SHALL also be covered at the service layer without requiring GraphQL.
#### Scenario: service checkout
- **WHEN** a cart qty exceeds SKU stock
- **THEN** `order::checkout` returns `ApiError::Conflict` and no order row is committed