docs: architecture, guidelines, domain designs, progressive code index
This commit is contained in:
@@ -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/`
|
||||
@@ -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`.
|
||||
@@ -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`
|
||||
@@ -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 |
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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**.
|
||||
Reference in New Issue
Block a user