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:
@@ -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` — 幂等演示数据。
|
||||
|
||||
## 硬性约定
|
||||
|
||||
@@ -13,6 +13,8 @@ apps/
|
||||
packages/
|
||||
shared/ @vmall/shared:TS 类型、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` 规范要求它仍能服务每个域,因此它是回滚路径,删除它会破坏回滚。
|
||||
|
||||
## 环境变量(后端)
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user