diff --git a/AGENTS.md b/AGENTS.md index 43463d6..2af421f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,10 @@ ## 布局与所有权 -- `apps/api` — Rust 后端(crate `vmall-api`)。迁移在 `apps/api/migrations/`(sqlx,启动时自动执行,只增不改)。代码按限界上下文放在 `src/modules//`(handler → service → repo);HTTP 抽取器在 `src/http/`。新功能写进对应模块,不要把 SQL 堆回 handler。简单 CRUD 允许 handler 直接调 repo。Service 返回 `ApiResult`,不依赖 `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//`(handler → service → repo);HTTP 抽取器在 `src/http/`。新功能写进对应模块,不要把 SQL 堆回 handler。简单 CRUD 允许 handler 直接调 repo。Service 返回 `ApiResult`,不依赖 `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` — 幂等演示数据。 ## 硬性约定 diff --git a/README.md b/README.md index 47ed5ca..f6ed5f5 100644 --- a/README.md +++ b/README.md @@ -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` 规范要求它仍能服务每个域,因此它是回滚路径,删除它会破坏回滚。 ## 环境变量(后端) diff --git a/docs/TBD-marketing.md b/docs/TBD-marketing.md new file mode 100644 index 0000000..d5311b3 --- /dev/null +++ b/docs/TBD-marketing.md @@ -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//` and archives green. diff --git a/docs/adr/0001-rust-api-modular-monolith.md b/docs/adr/0001-rust-api-modular-monolith.md new file mode 100644 index 0000000..effd910 --- /dev/null +++ b/docs/adr/0001-rust-api-modular-monolith.md @@ -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//`, 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. diff --git a/docs/adr/0002-keep-http-rest-not-graphql.md b/docs/adr/0002-keep-http-rest-not-graphql.md new file mode 100644 index 0000000..e69e87f --- /dev/null +++ b/docs/adr/0002-keep-http-rest-not-graphql.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..d1c4535 --- /dev/null +++ b/docs/adr/README.md @@ -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). diff --git a/docs/tech-specs/rust-api.md b/docs/tech-specs/rust-api.md new file mode 100644 index 0000000..bc8f6f7 --- /dev/null +++ b/docs/tech-specs/rust-api.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`, `AuthUser`, path/query/JSON. +2. Handler maps HTTP-only concerns (`StatusCode::CREATED`) after the service returns `ApiResult`. +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`: `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/` 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. diff --git a/openspec/config.yaml b/openspec/config.yaml index 2352cdd..9a3bccc 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -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/ (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). diff --git a/openspec/specs/api-architecture/spec.md b/openspec/specs/api-architecture/spec.md new file mode 100644 index 0000000..1314853 --- /dev/null +++ b/openspec/specs/api-architecture/spec.md @@ -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//` (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` 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