diff --git a/AGENTS.md b/AGENTS.md index ad8d676..c35587d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,12 @@ 本仓库是 VMall B2B2C 商城 MVP(pnpm + Cargo 双 workspace)。改动前先读本文件与 `README.md`;功能变更须同步 OpenSpec。 +## 文档与代码索引(docs/) + +- **定位代码先用 `docs/code_index/index.md`**:按业务域渐进披露到具体文件;新增/删除/重命名文件时**必须同步更新对应域的索引文件**。 +- 总体架构 `docs/architecture.md`;后端规范 `docs/backend-guidelines.md`;前端规范 `docs/frontend-guidelines.md`;领域设计 `docs/domains/`;开发/部署排障 `docs/development.md`。 +- 规范性的行为契约仍以 `openspec/specs/` 为准;docs 是实现地图,不是需求来源。 + ## 布局与所有权 - `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。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..46c51bc --- /dev/null +++ b/docs/architecture.md @@ -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/` diff --git a/docs/backend-guidelines.md b/docs/backend-guidelines.md new file mode 100644 index 0000000..06f0d73 --- /dev/null +++ b/docs/backend-guidelines.md @@ -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//`: `handlers.rs` (Axum extractors only) → `service.rs` +(`ApiResult`, 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/.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`. diff --git a/docs/code_index/catalog.md b/docs/code_index/catalog.md new file mode 100644 index 0000000..1a35d6a --- /dev/null +++ b/docs/code_index/catalog.md @@ -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` diff --git a/docs/code_index/consoles.md b/docs/code_index/consoles.md new file mode 100644 index 0000000..6c30930 --- /dev/null +++ b/docs/code_index/consoles.md @@ -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 | diff --git a/docs/code_index/index.md b/docs/code_index/index.md new file mode 100644 index 0000000..8fbb4cf --- /dev/null +++ b/docs/code_index/index.md @@ -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). diff --git a/docs/code_index/mall.md b/docs/code_index/mall.md new file mode 100644 index 0000000..598750c --- /dev/null +++ b/docs/code_index/mall.md @@ -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. diff --git a/docs/code_index/marketing.md b/docs/code_index/marketing.md new file mode 100644 index 0000000..dd21a7d --- /dev/null +++ b/docs/code_index/marketing.md @@ -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` diff --git a/docs/code_index/order.md b/docs/code_index/order.md new file mode 100644 index 0000000..5337ac4 --- /dev/null +++ b/docs/code_index/order.md @@ -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) diff --git a/docs/code_index/platform.md b/docs/code_index/platform.md new file mode 100644 index 0000000..f3c1902 --- /dev/null +++ b/docs/code_index/platform.md @@ -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` diff --git a/docs/code_index/post-order.md b/docs/code_index/post-order.md new file mode 100644 index 0000000..ae8b119 --- /dev/null +++ b/docs/code_index/post-order.md @@ -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` diff --git a/docs/code_index/shared.md b/docs/code_index/shared.md new file mode 100644 index 0000000..06f2822 --- /dev/null +++ b/docs/code_index/shared.md @@ -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. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..bfa69d0 --- /dev/null +++ b/docs/development.md @@ -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/ build # per app; required for every app when + # packages/shared changed +``` + +## OpenSpec flow + +1. New capability → `openspec/changes//` with proposal + tasks + spec + deltas; `openspec validate --strict` must pass. +2. Implement; check tasks as you go. +3. Verify (tests ×2, builds, browser smoke), then `openspec archive --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//.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 | diff --git a/docs/domains/aftersales-reviews.md b/docs/domains/aftersales-reviews.md new file mode 100644 index 0000000..5ab8e64 --- /dev/null +++ b/docs/domains/aftersales-reviews.md @@ -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`. diff --git a/docs/domains/catalog.md b/docs/domains/catalog.md new file mode 100644 index 0000000..71804c3 --- /dev/null +++ b/docs/domains/catalog.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`. diff --git a/docs/domains/marketing.md b/docs/domains/marketing.md new file mode 100644 index 0000000..71d9fe3 --- /dev/null +++ b/docs/domains/marketing.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`. diff --git a/docs/domains/orders.md b/docs/domains/orders.md new file mode 100644 index 0000000..ae24446 --- /dev/null +++ b/docs/domains/orders.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`. diff --git a/docs/domains/platform.md b/docs/domains/platform.md new file mode 100644 index 0000000..2526be4 --- /dev/null +++ b/docs/domains/platform.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`. diff --git a/docs/frontend-guidelines.md b/docs/frontend-guidelines.md new file mode 100644 index 0000000..57555fd --- /dev/null +++ b/docs/frontend-guidelines.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 `