chore(openspec): archive wave 6 and close the migration tracker
The merge creates the brand capability (two requirements) and extends the catalog browse requirement with the brand filter, the sales sort and a real sold count. openspec validate --all --strict stays green at 14 specs. With every box now checked, docs/TBD-migrate-wave.md is deleted, as its own instructions require. Its two durable notes move to the README instead of disappearing with it: - `nuxt build` does not typecheck, so a green build is not the type gate the verification section claimed. AGENTS.md told me to treat it as one, which is why waves 1-5 over-reported "builds pass" as evidence. - the list of what stays deliberately mock: addresses, coupons, favourites, account stats, the seckill/collective/integral pages, reviews, and the fixed-data adapter itself, which the adapter spec requires for rollback. The README's verification block also gains the real test count (29, not 16).
This commit is contained in:
@@ -60,13 +60,27 @@ pnpm dev:admin # :3002
|
||||
## 测试与校验
|
||||
|
||||
```bash
|
||||
cargo test -p vmall-api # 16 个集成测试(vmall_test + Redis)
|
||||
pnpm --filter @vmall/mall build # 各前端构建即类型校验
|
||||
cargo test -p vmall-api # 29 个集成测试(vmall_test + Redis)
|
||||
pnpm --filter @vmall/mall build # 打包;注意:并不做类型校验
|
||||
pnpm --filter @vmall/shop-admin build
|
||||
pnpm --filter @vmall/admin build
|
||||
openspec validate --all --strict # 规范校验
|
||||
```
|
||||
|
||||
`nuxt build` 不做类型校验:`nuxt.config.ts` 未开启 `typescript.typeCheck`,也未安装 `vue-tsc`。构建通过只说明能打包。要补上这道关:加 `vue-tsc` + `typescript` 依赖、设 `typescript: { typeCheck: true }`,首次运行会暴露一批既有错误。在那之前,**能证明页面可用的是浏览器实测,而不是构建**。
|
||||
|
||||
## 商城的 mock 边界
|
||||
|
||||
`apps/mall` 通过 `apps/mall/plugins/api.ts` 的 `liveDomains` 按域选择适配器。已有 API 的域全部走真实后端:catalog、currency、content、brands、shops、auth、cart、orders、shipments、invoices。
|
||||
|
||||
仍来自 `~/mock/data` 的部分是**有意保留**的展示内容,不是待办:
|
||||
|
||||
- **收货地址**(`MOCK_ADDRESSES`):`Address` 内嵌在订单里,结算直接在请求体里携带,不需要地址表。
|
||||
- **优惠券、收藏、账户统计**:纯展示,不参与交易。
|
||||
- **seckill / collective / integral 营销页**:纯展示内容。
|
||||
- **商品评价**:没有评价模型,所以商城不再展示任何评价——卡片上的评价数与详情页的评价页签、评分、回复都已移除,而不是继续展示虚构的评论者与评分。评价是独立功能,不是迁移的一部分。
|
||||
- **fixed-data 适配器本身**:`Mock API adapter` 规范要求它仍能服务每个域,因此它是回滚路径,删除它会破坏回滚。
|
||||
|
||||
## 环境变量(后端)
|
||||
|
||||
| 变量 | 默认值 |
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# TBD — migrate the mall off the mock API (waves 4+)
|
||||
|
||||
Waves 1–3 are captured in `openspec/changes/replace-mock-api-wave-{1,2,3}/`: catalog +
|
||||
currency, auth, and the transaction chain (cart, orders, shipments, invoices). Every domain
|
||||
the mall had an API for is now live; what remains in Wave 4 is the mock content that never
|
||||
had a backend behind it.
|
||||
|
||||
**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`).
|
||||
|
||||
**Delete this file** once every Wave 4 box is checked, or consciously dropped and recorded.
|
||||
The "deliberately out of scope" list at the bottom does not block deleting it.
|
||||
|
||||
---
|
||||
|
||||
## Wave 2 — auth (done)
|
||||
|
||||
Captured in `openspec/changes/replace-mock-api-wave-2/`; auth is live, with the session
|
||||
validated through `/auth/me` rather than trusted from `localStorage`.
|
||||
|
||||
- [x] Flip `auth` to live and verify `login` / `register` / `me` against `:8080` using the seeded `customer@vmall.local` / `customer123`.
|
||||
- [x] Confirm bad credentials now produce a real 401 — the mock accepted any input (`apps/mall/mock/api.ts:125`), so this is a deliberate UX change.
|
||||
- [x] Confirm the JWT round-trips through the `vmall.token` localStorage key, shared with shop-admin and admin, and that logout clears it.
|
||||
|
||||
## Wave 3 — the transaction chain: cart + orders + shipments + invoices
|
||||
|
||||
These move **together**, not one at a time. The mock adapter keeps
|
||||
`state.cart -> state.orders -> state.shipments` in a single shared state, so a partial flip
|
||||
leaves the mock half reading state the live half never populates:
|
||||
|
||||
- live cart + mock `checkout()` fails with `EMPTY_CART` (`apps/mall/mock/api.ts:194`),
|
||||
- mock `requestInvoice()` 404s on any live order id (`mock/api.ts:285`),
|
||||
- mock `listMyShipments()` returns shipments whose `order_id` matches no live order, so the
|
||||
shipment block is silently empty (`mock/api.ts:282`, `pages/user/orders/[id].vue:25`).
|
||||
|
||||
- [x] Flip `cart`, `orders`, `shipments` and `invoices` in the same change, then verify one purchase end to end: add to cart, check out into per-shop orders, pay, ship, confirm delivery, request an invoice. Done in `replace-mock-api-wave-3`; the merchant half was driven through the API because the mall has no merchant UI.
|
||||
- [x] Verify cancel restores stock and `payOrder` only accepts `pending_payment`. Cancel and stock restore are asserted by `cancel_rules_and_stock_restore`; `pay_order` is a status-guarded `UPDATE ... AND status = 'pending_payment'` that answers 409 otherwise (`apps/api/src/routes/orders.rs:279-287`).
|
||||
- [x] Verify a company invoice requires a tax number, and that one order can hold only one active invoice. Asserted by `invoice_lifecycle` (400 without a tax number, 409 on the second invoice).
|
||||
- [x] Remove cart's mock display coupling. `CartItemView` now carries `shop_id`, `shop_name` and `stock` (`apps/api/src/cart.rs`), and `pages/cart.vue` groups by them.
|
||||
- [x] Decide what caps cart quantity. `CartItem.stock` is exposed and the stepper caps at it, but the API deliberately does not check stock on add — checkout stays authoritative with its 409.
|
||||
- [x] Gate add-to-cart for anonymous shoppers: `pages/goods/[id].vue` sends a 401 to `/login?redirect=…`, and `pages/login.vue` honours only same-origin paths.
|
||||
- [x] Checkout keeps sourcing `shipping_address` from `MOCK_ADDRESSES` — intentional; see the out-of-scope note below.
|
||||
- [x] Fix contract debt: `Shipment.items` is optional and `Invoice.invoice_no` is nullable, matching what the API returns. `Invoice` was declared twice in `packages/shared/src/types.ts` and TypeScript merges duplicate interfaces, so the duplicate had to go for the change to take effect.
|
||||
- [x] Remove the remaining `storeById` mock usage on the order pages. They animate the generic store label instead; the real names need the public store read below.
|
||||
- [x] Confirm the mall still renders when the live API is down, with every domain configured to fixed data.
|
||||
|
||||
## Wave 4 — the mock content that never had an API
|
||||
|
||||
Each is a new backend capability rather than a domain flip.
|
||||
|
||||
- [x] **Storefront content** — banners, promos, quick links and floor advert art. Done in `replace-mock-api-wave-4`: four tables seeded from the existing assets, a public `GET /api/content/home`, and an admin read/replace pair.
|
||||
- [x] **Public store read** — a buyer-facing shop endpoint so `stores/index`, `stores/[id]` and the cart's shop grouping leave mock. Done in `replace-mock-api-wave-5`: a `shop_profiles` table, `GET /api/shops` + `GET /api/shops/{slug}`, an admin upsert, and the order surfaces now name their shop. Two things went rather than being faked: `distanceKm` (no geo model) and the store home's "best sellers" rail plus its sales/comments sorts (no sales model).
|
||||
- [x] **Brand model + sales/comments sorts** — Done in `replace-mock-api-wave-6` for the two that have a model: a `brands` table with a product column, a public read and an ordered admin replace, so the search facet is back; and `sort=sales` computed from `order_items` over orders that reached payment, with a real `sold_count` on every product payload. **Comments are not done and cannot be**: there is no reviews model, so the review UI went rather than staying as invented reviewers and ratings. Reviews are future work — see the out-of-scope note below.
|
||||
- [x] Extend the `ORDER BY` whitelist. Nothing more is wanted: `price` and `sales` are the two orderings with a model behind them, and any other value is a 400 by design.
|
||||
- [ ] *(adjacent, not part of the migration)* Move the session token to a cookie so SSR knows whether anyone is signed in. Today a full page load of a guarded route renders the page and then redirects on the client, which logs a hydration mismatch; it is pre-existing (verified identical before Wave 2) and harmless, but it is the real fix for the `ClientOnly` workarounds in `components/shell/TopBar.vue` and `pages/user.vue`.
|
||||
|
||||
---
|
||||
|
||||
## Deliberately out of scope — not tracked here, and they do not block deleting this file
|
||||
|
||||
These have no API contract and no backend model. Leaving them on `~/mock/data` is a decision, not a backlog item.
|
||||
|
||||
- **Addresses** — never a blocker: `Address` is embedded in the order and live checkout takes it in the request body, so no addresses table is needed. `MOCK_ADDRESSES` can stay behind checkout indefinitely.
|
||||
- **Favorites, coupons, account stats** — pure presentation, no transactional impact.
|
||||
- **Reviews** — the mall no longer presents any: the card's review count and the product detail page's reviews tab, summary and replies were removed in Wave 6 rather than kept as invented reviewers and ratings. Writing, moderating and displaying reviews is a feature with its own lifecycle, not a migration.
|
||||
- **seckill / collective / integral marketing pages** — display-only mock content.
|
||||
|
||||
## Decisions already made (do not relitigate)
|
||||
|
||||
- Category filtering is by **subtree**; the backend exact-match filter was the bug (fixed in Wave 1).
|
||||
- A migration wave must not change what the UI claims: facets without a backing model are removed rather than left matching nothing.
|
||||
- The mall is the last mock holdout; `shop-admin` and `admin` already run live against the same backend.
|
||||
- Auth flips independently, but the transaction domains do not: the mock's shared cart/order/shipment state makes any partial flip fail loudly.
|
||||
|
||||
## Known gap: the frontend builds do not typecheck
|
||||
|
||||
`pnpm --filter @vmall/<app> build` runs `nuxt build`, which does **not** typecheck: `nuxt.config.ts` sets no `typescript.typeCheck` and `vue-tsc` is not installed. AGENTS.md describes the build as the type gate, so a passing build has been read as type-safe across waves 1–5; it is not. Wave 5 found this the hard way — `export { lowestSku } from "~/utils/product"` creates no local binding, so every internal caller broke at runtime while the build stayed green.
|
||||
|
||||
Cheap fix when someone wants it: add `vue-tsc` + `typescript` as devDependencies, set `typescript: { typeCheck: true }`, and expect a backlog of pre-existing errors on first run. Until then treat the browser check, not the build, as the thing that proves a page works.
|
||||
@@ -0,0 +1,28 @@
|
||||
# brand Specification
|
||||
|
||||
## Purpose
|
||||
The product brand registry: a bilingual, admin-managed list of manufacturers that products reference, shoppers filter listings by, and the storefront shows on a product.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Public brand list
|
||||
`GET /api/brands` SHALL return the brands without authentication, ordered by their stored position, each with an id, slug and bilingual name. Products SHALL carry a nullable `brand_id` referring to that list, and `GET /api/products` SHALL accept a `brand_id` filter that composes with the existing category, shop and keyword filters.
|
||||
|
||||
#### Scenario: filter a listing by brand
|
||||
- **WHEN** a shopper filters the catalog by a brand
|
||||
- **THEN** only that brand's published products are returned, and the filter combines with a category filter rather than replacing it
|
||||
|
||||
#### Scenario: a product without a brand
|
||||
- **WHEN** a product has no brand assigned
|
||||
- **THEN** it is still listed and its `brand_id` is null rather than pointing at an invented brand
|
||||
|
||||
### Requirement: Brand management
|
||||
A platform admin SHALL replace the brand list with `PUT /api/admin/brands`, which validates each entry, applies as one transaction and reindexes positions from the submitted order; the write SHALL require the `platform_admin` role. Each entry SHALL carry a slug and non-empty `en` and `zh` names, and a rejected list SHALL leave the stored brands untouched.
|
||||
|
||||
#### Scenario: replace round-trips
|
||||
- **WHEN** an admin replaces the brands and then reads them publicly
|
||||
- **THEN** the public list matches, in the submitted order
|
||||
|
||||
#### Scenario: incomplete bilingual name is refused
|
||||
- **WHEN** an admin submits a brand with only `en` text
|
||||
- **THEN** the request fails and the stored brands are unchanged
|
||||
@@ -40,7 +40,7 @@ Each SKU SHALL carry `price_minor` (integer minor units) and an ISO `currency` c
|
||||
- **THEN** the API returns 400
|
||||
|
||||
### Requirement: Public product browse
|
||||
Public `GET /api/products` SHALL return only `published` products whose shop is active, and SHALL remain readable without authentication. When `category_id` is supplied, the filter SHALL match that category **and every category beneath it**, so requesting a parent category returns products assigned to its child and grandchild categories. The listing SHALL accept an optional `sort` of `price` together with an `order` of `asc` or `desc`, ordering by each product's lowest active SKU price; any other `sort` value SHALL be rejected with a 400 `ApiError` rather than silently ignored. An unsorted listing SHALL order newest first. Paging SHALL keep returning `page` and `per_page` alongside the filtered `total`.
|
||||
Public `GET /api/products` SHALL return only `published` products whose shop is active, and SHALL remain readable without authentication. When `category_id` is supplied, the filter SHALL match that category **and every category beneath it**, so requesting a parent category returns products assigned to its child and grandchild categories. When `brand_id` is supplied the filter SHALL match that brand and compose with the other filters. The listing SHALL accept an optional `sort` of `price` or `sales`: `price` orders by each product's lowest active SKU price, and `sales` orders by units sold across orders that reached payment, which SHALL also be reported per product as `sold_count`. Any other `sort` value SHALL be rejected with a 400 `ApiError` rather than silently ignored. An unsorted listing SHALL order newest first. Paging SHALL keep returning `page` and `per_page` alongside the filtered `total`.
|
||||
|
||||
#### Scenario: parent category includes descendant products
|
||||
- **WHEN** a shopper requests products for a category that has child categories holding published products
|
||||
@@ -50,8 +50,16 @@ Public `GET /api/products` SHALL return only `published` products whose shop is
|
||||
- **WHEN** a shopper requests the product list with `sort=price` and `order=asc`
|
||||
- **THEN** products come back ordered by their lowest active SKU price ascending
|
||||
|
||||
#### Scenario: sort by units sold
|
||||
- **WHEN** a shopper requests the product list with `sort=sales` and `order=desc`
|
||||
- **THEN** products come back ordered by their `sold_count` descending, and a product with no paid orders reports zero rather than being omitted
|
||||
|
||||
#### Scenario: filter by brand
|
||||
- **WHEN** a shopper requests products with a `brand_id` alongside a `category_id`
|
||||
- **THEN** only products matching both filters are returned, and `total` reflects the combined filter
|
||||
|
||||
#### Scenario: unsupported sort is rejected
|
||||
- **WHEN** a client requests a `sort` value that is not `price`
|
||||
- **WHEN** a client requests a `sort` value that is neither `price` nor `sales`
|
||||
- **THEN** the API responds 400 with an `ApiError` body instead of ignoring the parameter
|
||||
|
||||
#### Scenario: unpublished products never appear
|
||||
|
||||
@@ -76,11 +76,19 @@ The mall home page SHALL render a hero row composed of a pinned 240px category s
|
||||
- **THEN** the page still renders its category sidebar and product floors instead of failing
|
||||
|
||||
### Requirement: Product discovery pages
|
||||
The mall SHALL provide `/search` with breadcrumb, category and sort controls, a five-column desktop product grid, pagination and an empty state, listing products from the catalog API filtered by the selected category's subtree. The sort control SHALL offer newest-first and price ascending/descending only. It SHALL provide `/goods/[id]` rendering product and SKU data from the catalog API with image gallery/zoom, bilingual name/subtitle, integer-minor-unit prices, attribute and SKU selection, stock-aware quantity, store card, and detail/comments/after-sale tabs whose comment, coupon and sales content stays local display-only content.
|
||||
The mall SHALL provide `/search` with breadcrumb, category, brand and sort controls, a five-column desktop product grid, pagination and an empty state, listing products from the catalog API filtered by the selected category's subtree and brand. The sort control SHALL offer newest-first, price and sales. It SHALL provide `/goods/[id]` rendering product and SKU data from the catalog API with image gallery/zoom, bilingual name/subtitle, integer-minor-unit prices, attribute and SKU selection, stock-aware quantity, store card, and detail/after-sale tabs. Product cards and the product detail page SHALL show the product's real sold count, and the mall SHALL NOT present reviews, ratings or reviewer comments while no reviews capability exists.
|
||||
|
||||
#### Scenario: filter and inspect a product
|
||||
- **WHEN** a shopper filters the search page by a parent category and opens a product
|
||||
- **THEN** products from that category and its descendants are listed, and selecting an in-stock SKU updates the displayed price, stock and cart target from the catalog API
|
||||
- **WHEN** a shopper filters the search page by a parent category and a brand, then opens a product
|
||||
- **THEN** products from that category's subtree matching the brand are listed, and selecting an in-stock SKU updates the displayed price, stock and cart target from the catalog API
|
||||
|
||||
#### Scenario: sort by sales
|
||||
- **WHEN** a shopper sorts the search results by sales
|
||||
- **THEN** the order follows the products' reported sold counts
|
||||
|
||||
#### Scenario: no invented reviews
|
||||
- **WHEN** a shopper opens a product
|
||||
- **THEN** the page shows the shop's own after-sale copy and no rating, review count or reviewer comment
|
||||
|
||||
### Requirement: Mock transaction flow
|
||||
The mall SHALL provide a store-grouped cart, address-selecting checkout preview, payment selection and payment-success result, all reading and writing the live cart and order APIs. Quantity changes, removals, selection totals, checkout and payment SHALL be persisted by the backend for the signed-in shopper, so they survive a page reload.
|
||||
|
||||
Reference in New Issue
Block a user