docs: architecture, guidelines, domain designs, progressive code index

This commit is contained in:
Chengdong Zhang
2026-09-24 16:59:32 +08:00
parent c532f87b03
commit a968327b12
19 changed files with 728 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
# VMall Architecture
VMall is a B2B2C marketplace MVP: one Rust API serving three Nuxt 3 frontends,
with all HTTP contracts centralized in a shared TypeScript package.
## Workspace layout
```
apps/
api/ Rust (crate vmall-api), port 8080 — the only backend
mall/ Nuxt 3 shopper storefront (PC), port 3000
shop-admin/ Nuxt 3 merchant console, port 3001
admin/ Nuxt 3 platform console, port 3002
packages/
shared/ @vmall/shared — the ONLY API contract (types.ts + api.ts),
en/zh locale packs, Tailwind v4 theme tokens
ui/ @vmall/ui — shared Vue components (VBtn, VCard, VField,
VInput, VTable, VPage, VPanel, VBadge, VAccentSwatch)
openspec/ Spec-driven change management (specs/ = archived truth)
docs/ Architecture, guidelines, code index
```
## Request path
```
Browser ──► Nuxt (SSR + hydration) ──► $api plugin ──► vmall-api ──► Postgres
│ │
│ └─► Redis (cache/sessions)
└─ mock adapter fallback (mall only)
```
- Frontends never call `fetch` directly; they call the `ApiClient` from
`@vmall/shared`, provided as `$api` by each app's `plugins/api.ts`.
- The mall additionally composes a mock adapter (`apps/mall/mock/api.ts`) with
per-domain live picks (`LIVE_PICKS` in `apps/mall/plugins/api.ts`), so any
domain can roll back to deterministic fixtures via `NUXT_PUBLIC_LIVE_DOMAINS`.
The consoles are always live.
## Roles and tenancy
`platform_admin` (apps/admin), `shop_owner`/`shop_staff` (apps/shop-admin,
scoped to their shop via `auth.own_shop()`), `customer` (apps/mall). Every
protected route declares its roles; cross-shop access returns 404.
## Data rules that shape everything
- **Money**: `i64` minor units + ISO currency code everywhere. No floats. The
currency table carries the exponent (JPY=0); display goes through
`formatMoney(minor, code, exponent, locale)`.
- **User-facing content**: `{en, zh}` JSONB.
- **State machines**: guarded `UPDATE … WHERE status = …`; zero rows → 409.
- **Concurrent counters**: guarded `SET col = col - $q … AND col >= $q`;
replenish with `col + n`, never read-then-write.
- **Migrations**: `apps/api/migrations/`, append-only, run on boot.
## Where to look next
- Backend conventions → `docs/backend-guidelines.md`
- Frontend conventions → `docs/frontend-guidelines.md`
- Run/develop/test → `docs/development.md`
- Domain deep dives → `docs/domains/`
- Find the file for a feature → `docs/code_index/index.md`
- Normative behavior → `openspec/specs/`
+76
View File
@@ -0,0 +1,76 @@
# Backend Guidelines (vmall-api)
Normative layering lives in `docs/tech-specs/rust-api.md` and
`openspec/specs/api-architecture/spec.md`. This file is the practical playbook
with patterns proven by the existing modules.
## Layering
`src/modules/<ctx>/`: `handlers.rs` (Axum extractors only) → `service.rs`
(`ApiResult<Dto>`, no `Json`/`StatusCode`) → optional `repo.rs` (sqlx in
`&mut PgConnection` / `&mut Transaction`). Simple CRUD may call repo from
handlers. No generic Repository trait.
New domain checklist: migration → `models.rs` rows/enums → module
(`mod.rs`/`service.rs`/`handlers.rs`) → register in `modules/mod.rs` →
`apps/api/tests/<ctx>.rs` → shared contract → frontends.
## Errors
`ApiError` envelope `{"error":{"code","message"}}`. `NotFound` 404,
`BadRequest` 400, `Forbidden` 403, `Conflict` 409. Use `unique_conflict(err,
msg)` to map unique violations to 409.
## Concurrency and state machines (do not improvise)
```sql
-- status transition: condition on the expected prior state
UPDATE aftersales SET status = $1, updated_at = now()
WHERE id = $2 AND status = ANY($3) -- 0 rows → ApiError::Conflict
-- guarded decrement (stock): never SET col = col - n unconditionally
UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2
-- one-time flag (reply, reopen): guard on the empty state
UPDATE product_reviews SET reply = $2 WHERE id = $1 AND reply IS NULL
```
Multi-row `FOR UPDATE` must `ORDER BY` primary key. Ledger writes are
append-only: change a balance only via `account::service::credit/debit`
inside the caller's transaction, never by writing an absolute balance.
A credit in a currency the customer never held: create the zero-balance row
first (`account::service::ensure_monetary_account`).
## Money and i18n
`i64` minor units, `BIGINT` in SQL, `number` in TS. Currency conversion only
via `money::convert_minor(amount, from, to)`. User-facing text columns are
JSONB `{en, zh}`; validate with the `bilingual()` pattern (both locales
non-empty) or `some_locale()` (at least one — chat/message-style content).
## Watch out: Postgres type traps
- `SUM(bigint)` returns `NUMERIC` — always `COALESCE(SUM(x), 0)::bigint`
before decoding into `i64`. (Regression test: `tests/aftersales.rs` and
`tests/freight.rs` cover this.)
- `INSERT` column count must equal value count; when a table grows, update
**every** column-list constant (`ORDER_COLS`, `ORDER_ITEM_COLS`, …) and both
INSERT lists. sqlx decodes at runtime, not compile time — `cargo check`
green does not prove queries.
## Testing
`apps/api/tests/` with `tests/common/mod.rs` fixtures; the shared test DB is
never truncated, so fixtures use unique slugs/emails and tests assert only on
ids they created. `spawn_state()` initializes tracing (`RUST_LOG` works).
Run `cargo test -p vmall-api` twice before archiving a change; list/discovery
endpoints especially. Mind pipefail: `cargo test | grep` hides failures.
## Adding a column to an existing table
1. Migration: `ALTER TABLE … ADD COLUMN` (append-only file, never edit old ones
once applied anywhere).
2. Add the field to the model struct in `models.rs`.
3. Add the column to **every** SELECT/RETURNING list for that table.
4. Extend INSERT binds if writable.
5. Mirror in `packages/shared/src/types.ts`.
+28
View File
@@ -0,0 +1,28 @@
# Index: Catalog
Domain design: `docs/domains/catalog.md`. Specs: `openspec/specs/product/`,
`category/`, `brand/`.
## Backend (`apps/api`)
| File | Holds |
|---|---|
| `src/modules/product/service.rs` | public list/detail, merchant CRUD, publish/unpublish, SKU upsert, template link validation |
| `src/modules/product/repo.rs` | SKU attach + sold_count aggregation |
| `src/modules/category/` | public category tree |
| `src/modules/brand/` | public list; admin whole-list replacement |
| `migrations/0003_catalog.sql`, `0005_seed_category_tree.sql`, `0008_brands.sql` | schema + seeds |
## Shared contract
`types.ts`: `Product`, `Sku`, `Category`, `Brand`, `ProductStatus`.
`api.ts`: `listProducts`, `getProduct`, `listCategories`, `listBrands`,
`shop.*` product/SKU methods, `admin.replaceBrands`.
## Frontends
- Mall: `pages/index.vue` (floors), `pages/search.vue`, `pages/goods/[id].vue`,
`components/ui/ProductCard.vue`
- Shop-admin: `pages/products/{index,new,[id]}.vue`,
`components/ProductForm.vue`
- Admin: `pages/brands.vue`
+32
View File
@@ -0,0 +1,32 @@
# Index: Console apps (shop-admin, admin)
Both are live-only (no mock adapter), Tailwind-token layouts, auth middleware
per page. Navigation is a static list in each `app.vue`.
## apps/shop-admin (merchant console, :3001)
| Page | Domain |
|---|---|
| `pages/index.vue` | dashboard counters |
| `pages/products/{index,new,[id]}.vue` + `components/ProductForm.vue` | product/SKU CRUD, freight template link, SKU weight |
| `pages/orders/` | order list/detail + shipment creation (company selector) |
| `pages/shipments.vue` | shipment list, mark shipped |
| `pages/aftersales/` | after-sale processing workspace |
| `pages/reviews.vue` | review list + one-time reply |
| `pages/coupons.vue`, `flash-sales.vue`, `group-buying.vue` | merchant marketing |
| `pages/invoices.vue` | invoice issuing |
| `pages/freight-templates.vue` | freight templates + region rules |
| `pages/shop-profile.vue` | own shop profile self-edit |
## apps/admin (platform console, :3002)
| Page | Domain |
|---|---|
| `pages/index.vue` | platform overview |
| `pages/users.vue`, `pages/shops.vue` | users/roles, shop lifecycle |
| `pages/orders.vue` | read-only orders |
| `pages/aftersales.vue` | monitoring + dispute arbitration |
| `pages/reviews.vue` | moderation (hide/delete) |
| `pages/content.vue`, `pages/brands.vue` | storefront content, brand registry |
| `pages/currencies.vue` | currency registry + rates |
| `pages/points-products.vue`, `pages/points-orders.vue` | points mall ops |
+20
View File
@@ -0,0 +1,20 @@
# Code Index
Progressive disclosure: pick the domain, open its file, get the concrete
paths. **Update the affected domain file whenever you add/remove/rename
files it lists.**
| Domain | Covers | Index |
|---|---|---|
| Order & freight | checkout, orders, shipments, freight templates, shipping companies | [code_index/order.md](order.md) |
| Post-order | aftersales/refunds, reviews | [code_index/post-order.md](post-order.md) |
| Catalog | products, SKUs, categories, brands | [code_index/catalog.md](catalog.md) |
| Marketing | coupons, flash sales, group buying, points | [code_index/marketing.md](marketing.md) |
| Platform | identity, accounts/ledger, shops, storefront content, currencies, addresses | [code_index/platform.md](platform.md) |
| Shared contract | types, API client, locales, UI components | [code_index/shared.md](shared.md) |
| Storefront app | mall pages, mock adapter, live-domain wiring | [code_index/mall.md](mall.md) |
| Console apps | shop-admin, admin pages | [code_index/consoles.md](consoles.md) |
Cross-cutting references: `docs/architecture.md` (system),
`docs/backend-guidelines.md`, `docs/frontend-guidelines.md`,
`docs/development.md` (run/test), `openspec/specs/` (normative behavior).
+21
View File
@@ -0,0 +1,21 @@
# Index: Mall storefront app (`apps/mall`)
Guidelines: `docs/frontend-guidelines.md`.
| Path | Holds |
|---|---|
| `plugins/api.ts` | `$api` composition: mock base + per-domain live picks (`LiveDomain`, `LIVE_PICKS`, `DEFAULT_LIVE_DOMAINS`) |
| `mock/api.ts` | deterministic fixed-data adapter; versioned persisted state (`STORAGE_KEY`) |
| `mock/data.ts` | seed fixtures for the mock adapter |
| `nuxt.config.ts` | `liveDomains` runtime default (JSON array env `NUXT_PUBLIC_LIVE_DOMAINS` overrides) |
| `pages/index.vue` | home: banners/promos/quick links/category floors |
| `pages/search.vue`, `pages/goods/[id].vue` | catalog browsing; goods detail has reviews tab |
| `pages/cart.vue`, `pages/checkout/*` | cart and checkout (address → quote → submit → pay → success) |
| `pages/stores/*` | store directory + store detail |
| `pages/user/*` | buyer center: orders, aftersales, reviews, coupons, addresses, invoices, favorites |
| `pages/seckill.vue`, `collective.vue`, `integral.vue` | flash sale, group buying, points mall |
| `app.vue` + `components/shell/*` | header/footer shell |
| `composables/` | session/cart stores, `usePrice` (currency conversion display) |
Remember: mall is the only app with a mock adapter; every new live domain
needs mock parity so `NUXT_PUBLIC_LIVE_DOMAINS` rollback keeps working.
+25
View File
@@ -0,0 +1,25 @@
# Index: Marketing (coupons, flash sales, group buying, points)
Domain design: `docs/domains/marketing.md`. Specs: `openspec/specs/shop-coupons/`,
`flash-sales/`, `group-buying/`, `points-mall/`.
## Backend (`apps/api`)
| Module | Holds |
|---|---|
| `modules/coupon/` | template CRUD, claim, checkout discount + redemption |
| `modules/flash_sale/` | sessions/items CRUD, activity price resolution, reserved stock |
| `modules/group_buying/` | activities, groups, seat claim at payment |
| `modules/points/` | points catalog, redemption, fulfillment |
Migrations: `0011_shop_coupons.sql`, `0012_points_mall.sql`,
`0013_flash_sales.sql`, `0014_group_buying.sql`.
Tests: `tests/coupons.rs`, `tests/flash_sales.rs`, `tests/group_buying.rs`,
`tests/points.rs`.
## Frontends
- Mall: `pages/seckill.vue`, `pages/collective.vue`, `pages/integral.vue`,
`pages/user/coupons.vue`; checkout coupon selection in `pages/checkout/index.vue`
- Shop-admin: `pages/coupons.vue`, `pages/flash-sales.vue`, `pages/group-buying.vue`
- Admin: `pages/points-products.vue`, `pages/points-orders.vue`
+34
View File
@@ -0,0 +1,34 @@
# Index: Order & Freight
Domain design: `docs/domains/orders.md`. Specs: `openspec/specs/order/`,
`shipment/`, `shipping/`.
## Backend (`apps/api`)
| File | Holds |
|---|---|
| `src/modules/order/service.rs` | checkout transaction, pay/cancel, shipping quote |
| `src/modules/order/repo.rs` | `ORDER_COLS`/`ORDER_ITEM_COLS`, insert_order/insert_item, locks, status transitions |
| `src/modules/order/handlers.rs` | `/orders*` customer routes, `/shop/orders*`, `/admin/orders` |
| `src/modules/order/dto.rs` | OrderView, AddressBody, ShippingQuoteView |
| `src/modules/fulfillment/` | shipments: create (company-validated), ship, confirm-delivered |
| `src/modules/freight/service.rs` | template CRUD, region rules, `shop_fee()` calculation, company dictionary |
| `src/modules/freight/handlers.rs` | `/shop/freight-templates*`, `/shipping/companies` |
| `migrations/0004_orders.sql` | orders/order_items/shipments base schema |
| `migrations/0017_freight_templates.sql` | freight tables, `orders.shipping_fee_minor`, item snapshots, `skus.weight_grams` |
| `tests/orders.rs`, `tests/freight.rs` | integration coverage |
## Shared contract (`packages/shared/src`)
`types.ts`: `Order`, `OrderItem`, `Shipment`, `FreightTemplate*`,
`ShippingCompany`, `ShippingQuote`. `api.ts`: `checkout`, `quoteShipping`,
`listShippingCompanies`, shop freight CRUD, `createShipment`.
## Frontends
- Mall: `pages/checkout/{index,pay,success}.vue`, `pages/user/orders/`,
`pages/user/addresses.vue`
- Shop-admin: `pages/orders/`, `pages/shipments.vue`,
`pages/freight-templates.vue`, `components/ProductForm.vue`
(freight template + SKU weight)
- Admin: `pages/orders.vue` (read-only)
+30
View File
@@ -0,0 +1,30 @@
# Index: Platform (identity, accounts, shops, content, currency, addresses)
Domain design: `docs/domains/platform.md`. Specs: `openspec/specs/auth/`,
`customer-accounts/`, `store-directory/`, `storefront-content/`, `currency/`,
`address-book/`, `rbac/`.
## Backend (`apps/api`)
| Module | Holds |
|---|---|
| `modules/identity/` | register/login/me; admin user role assignment |
| `modules/account/` | customer_accounts ledger; credit/debit/freeze/release, `ensure_monetary_account` |
| `modules/shop/` | shop CRUD/status, profiles, `PUT /shop/profile` merchant self-write |
| `modules/content/` | four home-content kinds, whole-list replacement |
| `modules/currency/` | currency registry, rates, `convert` endpoint |
| `modules/address/` | customer address book |
| `src/auth.rs` | `AuthUser`, `require`, `own_shop`, `require_shop` |
| `src/error.rs` | `ApiError` envelope, `unique_conflict` |
| `src/money.rs` | `convert_minor` |
Tests: `tests/auth.rs`, `tests/accounts.rs`, `tests/shops.rs`,
`tests/content.rs`, `tests/addresses.rs`, `tests/catalog.rs` (currency too).
## Frontends
- Mall: `pages/login.vue`, `pages/register.vue`, header/footer shell in
`layouts/`/`app.vue`, `pages/user/addresses.vue`
- Shop-admin: `pages/shop-profile.vue`
- Admin: `pages/users.vue`, `pages/shops.vue`, `pages/content.vue`,
`pages/brands.vue`, `pages/currencies.vue`
+30
View File
@@ -0,0 +1,30 @@
# Index: Post-order (aftersales, reviews)
Domain design: `docs/domains/aftersales-reviews.md`. Specs:
`openspec/specs/aftersale/`, `reviews/`.
## Backend (`apps/api`)
| File | Holds |
|---|---|
| `src/modules/aftersale/service.rs` | state machine, refund completion (ledger-backed), arbitration, messages |
| `src/modules/aftersale/handlers.rs` | `/aftersales*`, `/shop/aftersales*`, `/admin/aftersales*` |
| `src/modules/review/service.rs` | create/reply/moderate, listing, rating summary, reviewable lines |
| `src/modules/review/handlers.rs` | `/products/{id}/reviews*`, `/me/reviewable`, `/reviews`, shop/admin routes |
| `migrations/0016_aftersales.sql` | aftersales + messages, `orders.refund_total_minor` |
| `migrations/0018_product_reviews.sql` | product_reviews |
| `tests/aftersales.rs`, `tests/reviews.rs` | integration coverage |
## Shared contract
`types.ts`: `Aftersale*`, `Review*`, `ReviewableItem`. `api.ts`: customer
aftersale/review methods, `shop.*` processing, `admin.*` arbitration/moderation.
## Frontends
- Mall: `pages/user/aftersales/{index,[id],apply}.vue`,
`pages/user/reviews.vue`, `pages/user/orders/[id].vue` (apply entry),
`pages/goods/[id].vue` (reviews tab); mock fixtures in `mock/api.ts`
(persisted `v6` state)
- Shop-admin: `pages/aftersales/{index,[id]}.vue`, `pages/reviews.vue`
- Admin: `pages/aftersales.vue`, `pages/reviews.vue`
+28
View File
@@ -0,0 +1,28 @@
# Index: Shared contract & UI
The single API contract. Every frontend change starts here when an endpoint
is added or a payload shape changes.
## packages/shared
| File | Holds |
|---|---|
| `src/types.ts` | every DTO shared across API and frontends (money as `number` minor units, `{en, zh}` as `LocalizedText`) |
| `src/api.ts` | `ApiClient` interface + `createApi()` live client; input-body types (`ProductUpsertBody`, `SkuUpsertBody`, …) |
| `src/locales/en.ts`, `zh.ts` | shared copy; app-specific keys go to the app's `locales-extra.ts`, never here from app work |
| `src/theme.css` | Tailwind v4 `@theme` tokens + `html[data-accent]` presets |
| `src/money.ts` | `formatMoney(minor, code, exponent, locale)` |
## packages/ui
`src/components/`: `VBtn`, `VBadge`, `VCard`, `VField`, `VInput`, `VPage`,
`VPanel`, `VTable`, `VAccentSwatch`; `useAccent()`. Auto-registered as a Nuxt
module in all three apps.
## Rules of engagement
- Adding an API method: declare in the interface, implement in `createApi`,
implement the mock in `apps/mall/mock/api.ts` (mall-facing) or
`unsupported()` (console-only), and add exact picks to
`apps/mall/plugins/api.ts` `LIVE_PICKS` when mall-facing.
- Build all three apps after any change here.
+61
View File
@@ -0,0 +1,61 @@
# Development Guide
## Prerequisites
- Postgres: `docker exec pg18 psql -U postgres` (postgres/postgres; databases
`vmall` for dev, `vmall_test` for tests). Redis: container `rdb8`.
- Rust stable + pnpm.
## Run the stack
```bash
cargo run -p vmall-api # API on :8080, migrations auto-apply on boot
pnpm --filter @vmall/mall dev # storefront :3000
pnpm --filter @vmall/shop-admin dev
pnpm --filter @vmall/admin dev
pnpm dev # all frontends at once
```
Demo credentials (`scripts/seed-demo.mjs`, idempotent):
`admin@vmall.local / admin1234` (platform), `shop@vmall.local / shop12345`
(demo merchant), `customer@vmall.local / customer123` (shopper).
## Test
```bash
cargo test -p vmall-api # integration tests against vmall_test + Redis
```
Rules: run the suite **twice** before archiving a change (the shared test DB is
never truncated; a single green run can be luck). Use `set -o pipefail` when
piping to grep, or `FAILED` lines get swallowed by grep's exit code.
Test DB migration drift (you edited an already-applied local migration):
`docker exec pg18 psql -U postgres -d vmall_test -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"` — migrations rebuild it on next run.
## Build
```bash
pnpm --filter @vmall/<app> build # per app; required for every app when
# packages/shared changed
```
## OpenSpec flow
1. New capability → `openspec/changes/<name>/` with proposal + tasks + spec
deltas; `openspec validate <name> --strict` must pass.
2. Implement; check tasks as you go.
3. Verify (tests ×2, builds, browser smoke), then `openspec archive <name> --yes`.
4. `openspec validate --all --strict` stays green.
The tigshop migration wave is tracked in `openspec/MIGRATION-PLAN.md`
(temporary; deleted when all eight changes are archived).
## Troubleshooting
| Symptom | Fix |
|---|---|
| `Failed to resolve import "#app-manifest"` | `rm -rf apps/<app>/.nuxt`, restart dev |
| Page stuck on loading after dev restart | stale tab holds dead chunk hashes — fresh tab |
| curl to `127.0.0.1:3000` refused | dev binds IPv6; use `localhost` |
| cargo test green locally but list test flaky | shared DB; assert only on own fixtures |
+45
View File
@@ -0,0 +1,45 @@
# Domain: After-sales and Reviews (post-order lifecycle)
## Aftersales (`modules/aftersale/`)
Per-order-item refund applications; the order's `refund_total_minor` is the
authoritative sum of completed refunds.
```
refund_only: pending ──approve──► approved ──refund──► refunded
return_refund: pending ──approve──► approved ──buyer tracking──► buyer_shipping
──confirm receipt──► merchant_confirmed ──refund──► refunded
pending ──reject──► rejected ──reopen (once)──► pending
any non-terminal ──buyer cancel──► cancelled
platform arbitration: pending ──► refunded | rejected (terminal)
```
Invariants:
- One active (non-terminal) aftersale per order item — partial unique index.
- Amount ≤ line paid − already refunded, checked at apply time.
- Refund completion is one transaction: guarded status flip + guarded order
`refund_total_minor` increment (`≤ total_minor`) + one ledger credit to the
customer's available balance in the **order currency** (the account row is
created lazily when missing). Retries hit the status guard — no double
credit. The `refund_completed` hook point is a `tracing` call today;
notifications attach there later.
- Eligibility: order paid/fulfilling/shipped/completed and updated within
`AFTERSALE_WINDOW_DAYS` (15).
- Messages are an append-only bilateral log (buyer/merchant/platform); readers
are the customer, the owning shop, and platform admins.
## Reviews (`modules/review/`)
One review per completed order line (unique index on `order_item_id`), rating
1–5 + bilingual content (at least one locale; display falls back) + image
URLs; immutable after creation. Merchant replies once (guarded
`WHERE reply IS NULL`). Platform moderation: `visible` → `hidden` (guarded)
or delete. Public listing and the SQL rating summary (count/avg/star
distribution) only see `visible` rows. `GET /me/reviewable` lists the
customer's completed lines without reviews.
## Key files
See `docs/code_index/post-order.md`.
+34
View File
@@ -0,0 +1,34 @@
# Domain: Catalog
Products, SKUs, categories, brands (`modules/product/`, `category/`, `brand/`).
## Shape
- `products`: shop-scoped, bilingual name/description, images JSONB array,
status draft → published (publish requires ≥1 active SKU with price > 0),
optional `freight_template_id` (must belong to the same shop).
- `skus`: per-product purchasable units — code, attributes JSONB,
`price_minor` + `currency`, guarded `stock`, `weight_grams` (freight).
- `categories`: tree with subtree CTE for filtering; seeded reference tree.
- `brands`: platform-managed ordered registry, whole-list replacement via
`PUT /admin/brands`.
## Public reads
`GET /products` (published + active shop only; filters: category subtree,
shop, name ILIKE, brand; sort newest/price/sales) and
`GET /products/{idOrSlug}`. `sold_count` is computed from paid order items.
Merchant reads/writes go through `/shop/products*` with `own_shop` scoping.
## Conventions
- Every Product query uses explicit column lists — when adding a product
column, update `PRODUCT_COLS` and the literal lists in `service.rs`
(search: `category_id, brand_id, slug`).
- SKU upsert is keyed on `(product_id, sku_code)`.
- Stock is the only concurrently decremented counter today; always the
guarded-decrement pattern.
## Key files
See `docs/code_index/catalog.md`.
+42
View File
@@ -0,0 +1,42 @@
# Domain: Marketing (coupons, flash sales, group buying, points)
Four activity systems, all shop-scoped where merchant-facing, all resolved
server-side at checkout.
## Coupons (`modules/coupon/`)
Merchant templates (amount/threshold/window/stock) → customers claim →
redeem at checkout. Locked in id order during checkout; the discount is
computed server-side in the order currency. Coupons never combine with
activity pricing (flash sale / group buying) on the same shop order.
## Flash sales (`modules/flash_sale/`)
Merchant sessions with per-SKU items (activity price + reserved stock).
Checkout splits a line into activity-priced and normal-priced units; activity
stock is consumed via guarded decrement. A SKU rejects overlapping
flash-sale and group-buying activities.
## Group buying (`modules/group_buying/`)
Merchant activity (required members, window, group lifetime). Checkout with a
group intent takes exactly one unit at the activity price; a seat is claimed
at payment (`paid_member_count` guarded), groups open/succeed/expire.
## Points mall (`modules/points/`)
Platform-curated catalog; customers redeem with the points account
(append-only ledger, guarded points decrement), fulfillment/cancellation by
platform admins.
## Shared rules
- All money integer minor units; activity prices convert to the order
currency server-side.
- All stock/seat counters use guarded `UPDATE … AND col >= $q`.
- Mall exposes each domain as a mock-adapter-backed live domain
(`LIVE_PICKS`); consoles are live-only.
## Key files
See `docs/code_index/marketing.md`.
+47
View File
@@ -0,0 +1,47 @@
# Domain: Orders, Checkout, Fulfillment, Freight
## Lifecycle
```
cart → checkout (one tx) → order per shop shipment flow
───────────────────────── ─────────────────────────
pending_payment ──pay──► paid ──first shipment──► fulfilling ──all shipped──► shipped
│ │
└──cancel──► cancelled delivered (all shipments) ──► completed
```
Every arrow is a guarded `UPDATE … WHERE status = …`; stale actions → 409.
## Checkout invariants (`modules/order/service.rs`)
One transaction per checkout: lock SKUs (`FOR UPDATE` in id order) → resolve
flash-sale activity pricing → per-shop coupon discount (server-computed,
client sends only coupon ids) → per-shop freight fee → insert order + items +
stock decrements → commit → clear cart. Orders are split per shop; each order
carries `total_minor = subtotal − discount + shipping_fee_minor`.
Snapshots make history immutable: order items keep `unit_price_minor`,
`product_name`, `flash_sale_item_id`, and the freight
`freight_template_id`/`freight_pricing_method` resolved at checkout. Later
template edits never touch past orders.
## Freight (`modules/freight/`)
Templates are shop-scoped (`own_shop`): by_piece or by_weight (grams on
`skus.weight_grams`), first-unit + additional-unit fees with part-unit
round-up, `always_free`, `free_threshold_minor`, and region rules overriding
default fees by case-insensitive match against address region/city/country.
Resolution per line: product template → shop default → zero fee. Fees merge
per template group within a shop order and convert from the group's SKU
currency to the order currency.
`POST /orders/shipping-quote` is the read-only quote (normal prices only);
checkout recomputes authoritatively in the order transaction. Client-supplied
fees are never read.
Shipping companies are a dictionary (`shipping_companies`); shipments record
`shipping_company_code` validated against it.
## Key files
See `docs/code_index/order.md`.
+39
View File
@@ -0,0 +1,39 @@
# Domain: Accounts, Content, Shops, Identity
## Customer accounts (`modules/account/`)
Three buckets per customer: `available` and `frozen` (monetary, per currency)
plus `points` (no currency). Balances change only through
`credit`/`debit`/`freeze`/`release`, each appending one immutable
`customer_account_entries` row in the same transaction as the business write.
The ledger is the audit trail; balances are never set absolutely. Credits in
a currency the customer never held lazily create the zero row
(`ensure_monetary_account`).
Planned on this foundation: wallet top-up/withdrawal and merchant settlement
(`openspec/changes/add-wallet-settlement`).
## Storefront content (`modules/content/`)
Four ordered home-content kinds (banners, promos, quick links, floor
adverts), platform-managed by whole-list replacement (`PUT
/admin/content/{kind}` reindexes positions atomically). Quick-link glyphs are
inline SVG path data.
## Shops (`modules/shop/`)
Platform creates/suspends shops; shop profiles are a side table with
bilingual address/notice/after-sale and platform-owned scores. Merchants edit
their own profile via `PUT /api/shop/profile` — scores are platform-only and
the merchant upsert never touches them.
## Identity (`modules/identity/`, `src/auth.rs`)
Email+password register/login, JWT bearer tokens. `AuthUser` carries
`id/role/shop_id`; `require(&[roles])` for RBAC, `own_shop()` /
`require_shop()` for tenant scoping (cross-shop resources return 404).
`ensure_accounts` runs inside the registration transaction.
## Key files
See `docs/code_index/platform.md`.
+67
View File
@@ -0,0 +1,67 @@
# Frontend Guidelines (mall / shop-admin / admin)
Three Nuxt 3 apps, one design system, one API contract. Ports are fixed:
mall 3000, shop-admin 3001, admin 3002.
## Hard rules
- **Contract only.** All API access goes through `$api` (the `ApiClient` from
`@vmall/shared`). Never build URLs or fetch manually. Contract changes happen
in `packages/shared/src/types.ts` + `api.ts` and must keep all three apps
building.
- **No `any` / `as any` / `@ts-ignore`.** Guard external data with types.
- **Money.** `formatMoney(minor, code, exponent, locale)` or the mall's
`PriceText` component. Never float math, never hardcode exponent 2.
- **i18n.** All copy via `$t()`. Missing keys go to the app's own
`locales-extra.ts` (mall: `locales/*.ts` modules), never to the shared packs
from an app change. Keep en/zh key sets identical.
- **Layout.** Tailwind v4 utilities with theme tokens (`bg-bg`, `text-text`,
`border-border`, `bg-surface`, `text-primary`, `text-danger`,
`text-success`, `text-muted`). No `<style scoped>`. Prefer `@vmall/ui`
components (`VBtn`, `VCard`, `VField`, `VInput`, `VTable`, `VPage`,
`VPanel`, `VBadge`) over raw elements.
## Page anatomy (follow it)
```vue
<script setup lang="ts">
definePageMeta({ middleware: "auth" }); // protected pages
const { $api } = useNuxtApp();
const loading = ref(true); // always handle loading
const errorMessage = ref(""); // visible failures, role="alert"
// load in onMounted (session lives in localStorage; SSR renders loading state)
</script>
<template>
<VPage :title="$t('nav.x')">…</VPage>
</template>
```
Patterns proven in the codebase: list page + inline row editing
(`apps/admin/pages/currencies.vue`), ordered whole-list replacement editors
(`apps/admin/pages/content.vue`, `brands.vue`), state-machine action pages with
409 surfacing (`apps/shop-admin/pages/aftersales/`).
## The mall mock boundary
`apps/mall/plugins/api.ts` composes `createMockApi()` with per-domain live
picks. Adding a mall-facing domain means:
1. implement the methods in `apps/mall/mock/api.ts` (deterministic, mutable,
persisted via the `STORAGE_KEY` versioned localStorage state),
2. add the domain to `LiveDomain` + `LIVE_PICKS` with **exact method picks**
(a missed method silently falls back to the mock while the domain looks
live),
3. enable it in `nuxt.config.ts` `liveDomains` and in mock seeds
(`apps/mall/mock/data.ts`) when new required model fields appear.
`NUXT_PUBLIC_LIVE_DOMAINS` is a JSON array env var (e.g. `'["catalog"]'`),
not a comma string.
## Dev-server pitfalls (learned the hard way)
- After `pnpm build`, dev servers need `rm -rf apps/<app>/.nuxt` or Vite fails
with `#app-manifest` pre-transform errors.
- Dev servers bind IPv6 localhost only; probe `localhost`, not `127.0.0.1`.
- Long-lived browser tabs across dev-server restarts lose hydration (stale
Vite chunk hashes). When a page sticks on "Loading…" with no console errors,
restart the dev server and open a **fresh tab**.