refactor(api): split catalog module into product, category, brand
Rearranges the Rust backend by domain: `modules/catalog` bundled four distinct concepts (Product, SKU, Category, Brand) behind one router and one 392-line service file, unlike every other module in the codebase which owns exactly one bounded aggregate. Splits into `modules/product` (Product/SKU, publish lifecycle, shop-scoped CRUD), `modules/category` (category tree, subtree query), and `modules/brand` (brand list, admin replace-all). `Category`/`Brand` move out of the shared `models.rs` into their owning modules; `Product`/`Sku` stay since `favorite`/`flash_sale`/`group_buying` reference them across modules. Purely internal restructuring — no route, schema, or behavior changes. Implements openspec change split-catalog-into-product-category-brand. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0b594c0147
commit
8446650baf
@@ -86,24 +86,6 @@ pub enum ProductStatus {
|
||||
Unpublished,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct Category {
|
||||
pub id: Uuid,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub name: serde_json::Value,
|
||||
pub slug: String,
|
||||
pub position: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct Brand {
|
||||
pub id: Uuid,
|
||||
pub name: serde_json::Value,
|
||||
pub slug: String,
|
||||
pub position: i32,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct Product {
|
||||
pub id: Uuid,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct Brand {
|
||||
pub id: Uuid,
|
||||
pub name: serde_json::Value,
|
||||
pub slug: String,
|
||||
pub position: i32,
|
||||
pub active: bool,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use axum::{routing::{get, put}, extract::State, Json, Router};
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::ApiResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::Brand;
|
||||
use super::service::{self, BrandInput};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/brands", get(list_brands))
|
||||
.route("/admin/brands", put(replace_brands))
|
||||
}
|
||||
|
||||
async fn list_brands(State(state): State<AppState>) -> ApiResult<Json<Vec<Brand>>> {
|
||||
Ok(Json(service::list_brands(&state).await?))
|
||||
}
|
||||
|
||||
async fn replace_brands(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<Vec<BrandInput>>,
|
||||
) -> ApiResult<Json<Vec<Brand>>> {
|
||||
auth.require_admin()?;
|
||||
Ok(Json(service::replace_brands(&state, body).await?))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mod dto;
|
||||
mod handlers;
|
||||
pub mod service;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
handlers::router()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::error::{unique_conflict, ApiError, ApiResult};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::Brand;
|
||||
|
||||
pub async fn list_brands(state: &AppState) -> ApiResult<Vec<Brand>> {
|
||||
Ok(sqlx::query_as::<_, Brand>(
|
||||
"SELECT id, name, slug, position, active FROM brands WHERE active = TRUE ORDER BY position, created_at",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BrandInput {
|
||||
pub slug: String,
|
||||
pub name: serde_json::Value,
|
||||
#[serde(default = "default_active")]
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
fn default_active() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn replace_brands(state: &AppState, body: Vec<BrandInput>) -> ApiResult<Vec<Brand>> {
|
||||
for brand in &body {
|
||||
if brand.slug.trim().is_empty()
|
||||
|| !brand
|
||||
.slug
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
{
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"invalid brand slug: {}",
|
||||
brand.slug
|
||||
)));
|
||||
}
|
||||
let ok = ["en", "zh"].iter().all(|code| {
|
||||
brand
|
||||
.name
|
||||
.get(code)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
});
|
||||
if !ok {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"brand {} needs non-empty en and zh names",
|
||||
brand.slug
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query("DELETE FROM brands").execute(&mut *tx).await?;
|
||||
for (i, brand) in body.iter().enumerate() {
|
||||
sqlx::query("INSERT INTO brands (name, slug, position, active) VALUES ($1, $2, $3, $4)")
|
||||
.bind(&brand.name)
|
||||
.bind(brand.slug.trim())
|
||||
.bind(i as i32)
|
||||
.bind(brand.active)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| unique_conflict(e, format!("duplicate brand slug: {}", brand.slug)))?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
list_brands(state).await
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct Category {
|
||||
pub id: Uuid,
|
||||
pub parent_id: Option<Uuid>,
|
||||
pub name: serde_json::Value,
|
||||
pub slug: String,
|
||||
pub position: i32,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use axum::{extract::State, routing::get, Json, Router};
|
||||
|
||||
use crate::error::ApiResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::Category;
|
||||
use super::service;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/categories", get(list_categories))
|
||||
}
|
||||
|
||||
async fn list_categories(State(state): State<AppState>) -> ApiResult<Json<Vec<Category>>> {
|
||||
Ok(Json(service::list_categories(&state).await?))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mod dto;
|
||||
mod handlers;
|
||||
pub mod service;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
handlers::router()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use crate::error::ApiResult;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::Category;
|
||||
|
||||
pub(crate) const SUBTREE_CTE: &str = "WITH RECURSIVE subtree AS (
|
||||
SELECT id FROM categories WHERE id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT c.id FROM categories c JOIN subtree st ON c.parent_id = st.id
|
||||
) ";
|
||||
|
||||
pub async fn list_categories(state: &AppState) -> ApiResult<Vec<Category>> {
|
||||
Ok(sqlx::query_as::<_, Category>(
|
||||
"SELECT id, parent_id, name, slug, position FROM categories ORDER BY position, slug",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
pub mod account;
|
||||
pub mod address;
|
||||
pub mod billing;
|
||||
pub mod brand;
|
||||
pub mod cart;
|
||||
pub mod catalog;
|
||||
pub mod category;
|
||||
pub mod content;
|
||||
pub mod coupon;
|
||||
pub mod currency;
|
||||
@@ -14,6 +15,7 @@ pub mod health;
|
||||
pub mod identity;
|
||||
pub mod order;
|
||||
pub mod points;
|
||||
pub mod product;
|
||||
pub mod shop;
|
||||
|
||||
use axum::Router;
|
||||
@@ -27,7 +29,9 @@ pub fn api_router() -> Router<AppState> {
|
||||
.merge(address::router())
|
||||
.merge(identity::router())
|
||||
.merge(currency::router())
|
||||
.merge(catalog::router())
|
||||
.merge(product::router())
|
||||
.merge(category::router())
|
||||
.merge(brand::router())
|
||||
.merge(content::router())
|
||||
.merge(cart::router())
|
||||
.merge(coupon::router())
|
||||
|
||||
+3
-23
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post, put},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -10,19 +10,16 @@ use uuid::Uuid;
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::ApiResult;
|
||||
use crate::http::Paged;
|
||||
use crate::models::{Brand, Category, ProductStatus, Sku};
|
||||
use crate::models::{ProductStatus, Sku};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::{ProductBody, ProductWithSkus, PublicListQuery, SkuBody};
|
||||
use super::service::{self, BrandInput};
|
||||
use super::service;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/products", get(list_products))
|
||||
.route("/products/{id_or_slug}", get(get_product))
|
||||
.route("/categories", get(list_categories))
|
||||
.route("/brands", get(list_brands))
|
||||
.route("/admin/brands", put(replace_brands))
|
||||
.route("/shop/products", get(shop_list).post(create_product))
|
||||
.route("/shop/products/{id}", get(shop_get).put(update_product))
|
||||
.route("/shop/products/{id}/publish", post(publish))
|
||||
@@ -44,23 +41,6 @@ async fn get_product(
|
||||
Ok(Json(service::get_public(&state, &id_or_slug).await?))
|
||||
}
|
||||
|
||||
async fn list_categories(State(state): State<AppState>) -> ApiResult<Json<Vec<Category>>> {
|
||||
Ok(Json(service::list_categories(&state).await?))
|
||||
}
|
||||
|
||||
async fn list_brands(State(state): State<AppState>) -> ApiResult<Json<Vec<Brand>>> {
|
||||
Ok(Json(service::list_brands(&state).await?))
|
||||
}
|
||||
|
||||
async fn replace_brands(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<Vec<BrandInput>>,
|
||||
) -> ApiResult<Json<Vec<Brand>>> {
|
||||
auth.require_admin()?;
|
||||
Ok(Json(service::replace_brands(&state, body).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ShopListQuery {
|
||||
page: Option<i64>,
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::error::{unique_conflict, ApiError, ApiResult};
|
||||
use crate::http::{clamp_page, clamp_per_page, Paged};
|
||||
use crate::models::{Brand, Category, Product, ProductStatus, Sku};
|
||||
use crate::models::{Product, ProductStatus, Sku};
|
||||
use crate::modules::category::service::SUBTREE_CTE;
|
||||
use crate::state::AppState;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -14,12 +15,6 @@ const SOLD_UNITS: &str = "(SELECT COALESCE(SUM(oi.qty), 0)
|
||||
WHERE sk.product_id = p.id
|
||||
AND o.status IN ('paid', 'fulfilling', 'shipped', 'completed'))";
|
||||
|
||||
const SUBTREE_CTE: &str = "WITH RECURSIVE subtree AS (
|
||||
SELECT id FROM categories WHERE id = $1::uuid
|
||||
UNION ALL
|
||||
SELECT c.id FROM categories c JOIN subtree st ON c.parent_id = st.id
|
||||
) ";
|
||||
|
||||
const MIN_PRICE: &str =
|
||||
"(SELECT MIN(price_minor) FROM skus WHERE product_id = p.id AND active = TRUE)";
|
||||
|
||||
@@ -111,78 +106,6 @@ pub async fn get_public(state: &AppState, id_or_slug: &str) -> ApiResult<Product
|
||||
Ok(items.remove(0))
|
||||
}
|
||||
|
||||
pub async fn list_categories(state: &AppState) -> ApiResult<Vec<Category>> {
|
||||
Ok(sqlx::query_as::<_, Category>(
|
||||
"SELECT id, parent_id, name, slug, position FROM categories ORDER BY position, slug",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn list_brands(state: &AppState) -> ApiResult<Vec<Brand>> {
|
||||
Ok(sqlx::query_as::<_, Brand>(
|
||||
"SELECT id, name, slug, position, active FROM brands WHERE active = TRUE ORDER BY position, created_at",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct BrandInput {
|
||||
pub slug: String,
|
||||
pub name: serde_json::Value,
|
||||
#[serde(default = "default_active")]
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
fn default_active() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn replace_brands(state: &AppState, body: Vec<BrandInput>) -> ApiResult<Vec<Brand>> {
|
||||
for brand in &body {
|
||||
if brand.slug.trim().is_empty()
|
||||
|| !brand
|
||||
.slug
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
{
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"invalid brand slug: {}",
|
||||
brand.slug
|
||||
)));
|
||||
}
|
||||
let ok = ["en", "zh"].iter().all(|code| {
|
||||
brand
|
||||
.name
|
||||
.get(code)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
});
|
||||
if !ok {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"brand {} needs non-empty en and zh names",
|
||||
brand.slug
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query("DELETE FROM brands").execute(&mut *tx).await?;
|
||||
for (i, brand) in body.iter().enumerate() {
|
||||
sqlx::query("INSERT INTO brands (name, slug, position, active) VALUES ($1, $2, $3, $4)")
|
||||
.bind(&brand.name)
|
||||
.bind(brand.slug.trim())
|
||||
.bind(i as i32)
|
||||
.bind(brand.active)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| unique_conflict(e, format!("duplicate brand slug: {}", brand.slug)))?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
list_brands(state).await
|
||||
}
|
||||
|
||||
async fn load_own_product(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<Product> {
|
||||
sqlx::query_as::<_, Product>(
|
||||
"SELECT id, shop_id, category_id, brand_id, slug, name, description, images, status, created_at, updated_at
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-22
|
||||
@@ -0,0 +1,40 @@
|
||||
## Context
|
||||
|
||||
`apps/api/src/modules/catalog` (dto/handlers/repo/service, 693 lines) currently owns four concepts: `Product`, `Sku`, `Category`, `Brand`. Routes are already flat resource paths (`/api/products`, `/api/categories`, `/api/brands`, `/api/admin/brands`, `/api/shop/products*`) rather than nested under `/api/catalog/*`, so the module boundary is purely internal — no client or `packages/shared` type depends on the Rust module name. `Category` has no other module dependents (checked: `content`, `points`, `group_buying`, `flash_sale` don't reference it). `Brand` is referenced only from `catalog::handlers`/`service`. `Product`/`Sku` are referenced by `favorite`, `flash_sale`, `group_buying` via `crate::models`, so those two stay in the shared `models.rs`.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Give Product/SKU, Category, and Brand each their own top-level module under `apps/api/src/modules/`, following the existing `dto.rs`/`handlers.rs`/`repo.rs`/`service.rs` shape used by every other module.
|
||||
- Zero behavior change: identical routes, request/response JSON, SQL, and error codes.
|
||||
- Keep `Product`/`Sku` in `crate::models` (cross-module dependents); move `Category`/`Brand` out of `models.rs` into their new modules since nothing outside references them.
|
||||
|
||||
**Non-Goals:**
|
||||
- No API versioning, route renaming, or DB schema/migration changes.
|
||||
- No behavior changes to sorting, filtering, or the subtree category-matching CTE.
|
||||
- Not folding Brand into Category — they're kept as separate modules per the chosen split (Option B from exploration), since they have independent admin-replace vs. tree-query shapes.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Three modules, not two.** Category and Brand are both small, platform-owned lookup tables with no lifecycle, but they don't share logic (recursive CTE vs. flat replace-all transaction) — merging them into one `taxonomy` module would just recreate a smaller version of the same problem. Alternative considered: `product` + `taxonomy` (category+brand together) — rejected per explicit user choice.
|
||||
- **`Product`/`Sku` stay in `models.rs`; `Category`/`Brand` move out.** Determined by actual cross-module usage (grepped `crate::models::{...}` across all modules): `Sku`/`Product` are imported by `flash_sale`, `group_buying`, `favorite`; `Category`/`Brand` are imported nowhere outside the old `catalog` module. This matches the existing pattern where module-local types (e.g. `Shop`, `Coupon`, `AddressBookEntry`) already live in `models.rs` alongside genuinely shared ones — moving `Category`/`Brand` doesn't fully break that existing pattern, but reduces unnecessary sharing surface for types nothing else touches.
|
||||
- **`repo.rs`'s `attach_skus` moves to `product/repo.rs` unchanged.** It only touches `Product`/`Sku`/order tables — no category/brand coupling.
|
||||
- **No spec deltas.** Per `openspec/specs/catalog/spec.md` and `openspec/specs/brand/spec.md`, this change alters no requirement text — those specs describe HTTP-level behavior which is untouched. `docs/tech-specs/rust-api.md` (if it names `modules/catalog`) gets a doc update as part of tasks, not a spec change.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Import churn** → every file that did `use crate::models::{Brand, Category, Product, ...}` needs updating; mitigated by compiler errors making every missed reference a hard build failure, not a silent bug.
|
||||
- **Merge/rebase conflicts** for anyone with in-flight branches touching `modules/catalog` → mitigated by doing the move as one atomic commit and merging promptly.
|
||||
- **sqlx query cache / compile-time verification** (if `sqlx::query!` macros or `.sqlx/` cache are used) → verified `catalog/service.rs` uses `sqlx::query_as::<_, T>(&format!(...))` (runtime-checked, not compile-time macros), so no `cargo sqlx prepare` step is needed.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Create `modules/product`, `modules/category`, `modules/brand` with the moved code (see tasks.md for the file-by-file split).
|
||||
2. Update `modules/mod.rs` router merge list and `models.rs`.
|
||||
3. `cargo build` and `cargo test` (integration tests hit HTTP routes, unaffected by internal module names) to confirm zero behavior drift.
|
||||
4. Delete `modules/catalog/`.
|
||||
5. Single commit/PR — no incremental rollout needed since this is a same-process, same-deploy internal refactor. Rollback is a plain `git revert`.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should `apps/api/tests/catalog.rs` be renamed/split into `product.rs`/`category.rs`/`brand.rs` to mirror the new module boundaries, or left as-is since it already tests via HTTP and doesn't reference Rust module paths? (Default: leave as-is unless the user wants test-file parity too — captured as an optional task.)
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
`apps/api/src/modules/catalog` currently bundles four distinct concepts — Product, SKU, Category, and Brand — behind one module, one router, and one 392-line service file. Every other module in the codebase (`favorite`, `coupon`, `flash_sale`, `address`, ...) owns exactly one bounded aggregate. `catalog` is the outlier, and it mixes a shop-owned, stateful aggregate (Product/SKU with a publish lifecycle) with platform-owned, admin-curated lookup data (Category tree, Brand list) that has no lifecycle of its own. Splitting now, while the module is still small, avoids compounding the mismatch as more product-side logic (variants, attributes) gets added.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Split `modules/catalog` into three top-level modules: `modules/product` (Product + Sku, publish lifecycle, shop-scoped CRUD), `modules/category` (Category tree, subtree query), and `modules/brand` (Brand list, admin replace-all).
|
||||
- Move `Category` and `Brand` structs out of the shared `models.rs` into their owning modules' `dto.rs`/models, following the pattern already used by `Shop`, `Coupon`, etc. staying in `models.rs` only where genuinely cross-module (Product/Sku stay in `models.rs` since `flash_sale`/`group_buying`/`favorite` reference them).
|
||||
- Register the three new modules in `modules/mod.rs`, removing `catalog` from the router merge list.
|
||||
- No route paths, request/response shapes, or database schema change — `/api/products`, `/api/categories`, `/api/brands`, `/api/admin/brands`, and `/api/shop/products*` are unaffected.
|
||||
- No SQL query text changes beyond moving them to new files.
|
||||
|
||||
## Capabilities
|
||||
|
||||
No system behavior changes. To keep spec capability boundaries aligned with the new Rust module boundaries, the existing `catalog` capability is re-filed (not behaviorally changed) into two capabilities along the same line the code splits on:
|
||||
|
||||
### New Capabilities
|
||||
- `product`: product/SKU content, publish lifecycle, shop isolation, SKU pricing, and public product browse/filter/sort — carried over verbatim from `catalog`.
|
||||
- `category`: the category tree and its use as a browse filter (including subtree matching) — carried over verbatim from `catalog`.
|
||||
|
||||
### Modified Capabilities
|
||||
- `catalog`: requirements removed (re-filed into `product` and `category` above with identical text/scenarios; no behavior change). `brand` is unaffected and stays a separate capability as it already was.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code**: `apps/api/src/modules/catalog/*` deleted; replaced by `apps/api/src/modules/product/*`, `apps/api/src/modules/category/*`, `apps/api/src/modules/brand/*`. `apps/api/src/modules/mod.rs` and `apps/api/src/models.rs` updated.
|
||||
- **Tests**: `apps/api/tests/catalog.rs` stays as-is (integration tests hit HTTP routes, which are unchanged) or is optionally renamed/split to mirror the new module boundaries.
|
||||
- **API/DB**: none — purely internal Rust restructuring.
|
||||
- **Frontends / `packages/shared`**: none — no contract change.
|
||||
@@ -0,0 +1,77 @@
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Localized product content
|
||||
Product and category names/descriptions SHALL be stored as JSONB maps keyed by locale (`en`, `zh`). The API MUST return the full map; clients pick the display locale.
|
||||
|
||||
#### Scenario: bilingual round-trip
|
||||
- **WHEN** a shop owner creates a product with name `{"en": "Mug", "zh": "马克杯"}`
|
||||
- **THEN** both public detail and shop-admin GET return the identical map
|
||||
|
||||
**Reason**: The `catalog` capability is split so its requirements are filed under the new `product` and `category` capabilities, mirroring the Rust module split (`modules/product`, `modules/category`). No behavior changes.
|
||||
**Migration**: See `specs/product/spec.md` ("Localized product content") for the identical requirement and scenario.
|
||||
|
||||
### Requirement: Publish lifecycle
|
||||
Products SHALL have status `draft | published | unpublished`. Only `published` products appear in public list/detail.
|
||||
|
||||
#### Scenario: publish then unpublish
|
||||
- **WHEN** a product is published
|
||||
- **THEN** it appears in `GET /api/products`
|
||||
- **WHEN** it is unpublished
|
||||
- **THEN** public detail returns 404 and it disappears from listings
|
||||
|
||||
#### Scenario: publish requires sellable SKU
|
||||
- **WHEN** publishing a product with no active SKU having price > 0
|
||||
- **THEN** the API returns 400
|
||||
|
||||
**Reason**: Re-filed under `product` capability; no behavior change.
|
||||
**Migration**: See `specs/product/spec.md` ("Publish lifecycle").
|
||||
|
||||
### Requirement: Shop isolation
|
||||
Shop-role users SHALL only see and mutate their own shop's products under `/api/shop/products`.
|
||||
|
||||
#### Scenario: cross-shop access denied
|
||||
- **WHEN** shop owner A requests `/api/shop/products/{id}` of shop B
|
||||
- **THEN** the API returns 404
|
||||
|
||||
**Reason**: Re-filed under `product` capability; no behavior change.
|
||||
**Migration**: See `specs/product/spec.md` ("Shop isolation").
|
||||
|
||||
### Requirement: SKU pricing
|
||||
Each SKU SHALL carry `price_minor` (integer minor units) and an ISO `currency` code; stock is a non-negative integer.
|
||||
|
||||
#### Scenario: negative stock rejected
|
||||
- **WHEN** upserting a SKU with stock < 0
|
||||
- **THEN** the API returns 400
|
||||
|
||||
**Reason**: Re-filed under `product` capability; no behavior change.
|
||||
**Migration**: See `specs/product/spec.md` ("SKU pricing").
|
||||
|
||||
### Requirement: Public product browse
|
||||
Public `GET /api/products` SHALL return only `published` products whose shop is active, and SHALL remain readable without authentication. When `category_id` is supplied, the filter SHALL match that category **and every category beneath it**, so requesting a parent category returns products assigned to its child and grandchild categories. When `brand_id` is supplied the filter SHALL match that brand and compose with the other filters. The listing SHALL accept an optional `sort` of `price` or `sales`: `price` orders by each product's lowest active SKU price, and `sales` orders by units sold across orders that reached payment, which SHALL also be reported per product as `sold_count`. Any other `sort` value SHALL be rejected with a 400 `ApiError` rather than silently ignored. An unsorted listing SHALL order newest first. Paging SHALL keep returning `page` and `per_page` alongside the filtered `total`.
|
||||
|
||||
#### Scenario: parent category includes descendant products
|
||||
- **WHEN** a shopper requests products for a category that has child categories holding published products
|
||||
- **THEN** the response contains the products assigned to those descendant categories, not only those assigned directly to the requested category
|
||||
|
||||
#### Scenario: sort by lowest active SKU price
|
||||
- **WHEN** a shopper requests the product list with `sort=price` and `order=asc`
|
||||
- **THEN** products come back ordered by their lowest active SKU price ascending
|
||||
|
||||
#### Scenario: sort by units sold
|
||||
- **WHEN** a shopper requests the product list with `sort=sales` and `order=desc`
|
||||
- **THEN** products come back ordered by their `sold_count` descending, and a product with no paid orders reports zero rather than being omitted
|
||||
|
||||
#### Scenario: filter by brand
|
||||
- **WHEN** a shopper requests products with a `brand_id` alongside a `category_id`
|
||||
- **THEN** only products matching both filters are returned, and `total` reflects the combined filter
|
||||
|
||||
#### Scenario: unsupported sort is rejected
|
||||
- **WHEN** a client requests a `sort` value that is neither `price` nor `sales`
|
||||
- **THEN** the API responds 400 with an `ApiError` body instead of ignoring the parameter
|
||||
|
||||
#### Scenario: unpublished products never appear
|
||||
- **WHEN** any public listing or filter is applied
|
||||
- **THEN** products that are not `published`, or whose shop is not active, are absent from both `items` and `total`
|
||||
|
||||
**Reason**: Re-filed under `product` capability (the listing endpoint itself, its lifecycle/shop-isolation/sort/paging contract); the category-subtree matching behavior it depends on is separately documented under the new `category` capability. No behavior change.
|
||||
**Migration**: See `specs/product/spec.md` ("Public product browse") and `specs/category/spec.md` ("Category subtree browse filtering").
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Localized category content
|
||||
Category names SHALL be stored as JSONB maps keyed by locale (`en`, `zh`). The API MUST return the full map; clients pick the display locale.
|
||||
|
||||
#### Scenario: bilingual round-trip
|
||||
- **WHEN** an admin creates a category with name `{"en": "Mugs", "zh": "马克杯"}`
|
||||
- **THEN** `GET /api/categories` returns the identical map for that category
|
||||
|
||||
### Requirement: Category subtree browse filtering
|
||||
`GET /api/categories` SHALL return the full category tree as a flat list, each with `id`, `parent_id`, `name`, `slug`, and `position`, ordered by `position` then `slug`. When a product listing is filtered by `category_id`, the filter SHALL match that category **and every category beneath it** in the tree, so requesting a parent category returns products assigned to its child and grandchild categories.
|
||||
|
||||
#### Scenario: parent category includes descendant products
|
||||
- **WHEN** a shopper requests products for a category that has child categories holding published products
|
||||
- **THEN** the response contains the products assigned to those descendant categories, not only those assigned directly to the requested category
|
||||
@@ -0,0 +1,62 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Localized product content
|
||||
Product names/descriptions SHALL be stored as JSONB maps keyed by locale (`en`, `zh`). The API MUST return the full map; clients pick the display locale.
|
||||
|
||||
#### Scenario: bilingual round-trip
|
||||
- **WHEN** a shop owner creates a product with name `{"en": "Mug", "zh": "马克杯"}`
|
||||
- **THEN** both public detail and shop-admin GET return the identical map
|
||||
|
||||
### Requirement: Publish lifecycle
|
||||
Products SHALL have status `draft | published | unpublished`. Only `published` products appear in public list/detail.
|
||||
|
||||
#### Scenario: publish then unpublish
|
||||
- **WHEN** a product is published
|
||||
- **THEN** it appears in `GET /api/products`
|
||||
- **WHEN** it is unpublished
|
||||
- **THEN** public detail returns 404 and it disappears from listings
|
||||
|
||||
#### Scenario: publish requires sellable SKU
|
||||
- **WHEN** publishing a product with no active SKU having price > 0
|
||||
- **THEN** the API returns 400
|
||||
|
||||
### Requirement: Shop isolation
|
||||
Shop-role users SHALL only see and mutate their own shop's products under `/api/shop/products`.
|
||||
|
||||
#### Scenario: cross-shop access denied
|
||||
- **WHEN** shop owner A requests `/api/shop/products/{id}` of shop B
|
||||
- **THEN** the API returns 404
|
||||
|
||||
### Requirement: SKU pricing
|
||||
Each SKU SHALL carry `price_minor` (integer minor units) and an ISO `currency` code; stock is a non-negative integer.
|
||||
|
||||
#### Scenario: negative stock rejected
|
||||
- **WHEN** upserting a SKU with stock < 0
|
||||
- **THEN** the API returns 400
|
||||
|
||||
### Requirement: Public product browse
|
||||
Public `GET /api/products` SHALL return only `published` products whose shop is active, and SHALL remain readable without authentication. When `category_id` is supplied, the filter SHALL match that category and every category beneath it (per the `category` capability's subtree matching). When `brand_id` is supplied the filter SHALL match that brand and compose with the other filters. The listing SHALL accept an optional `sort` of `price` or `sales`: `price` orders by each product's lowest active SKU price, and `sales` orders by units sold across orders that reached payment, which SHALL also be reported per product as `sold_count`. Any other `sort` value SHALL be rejected with a 400 `ApiError` rather than silently ignored. An unsorted listing SHALL order newest first. Paging SHALL keep returning `page` and `per_page` alongside the filtered `total`.
|
||||
|
||||
#### Scenario: parent category includes descendant products
|
||||
- **WHEN** a shopper requests products for a category that has child categories holding published products
|
||||
- **THEN** the response contains the products assigned to those descendant categories, not only those assigned directly to the requested category
|
||||
|
||||
#### Scenario: sort by lowest active SKU price
|
||||
- **WHEN** a shopper requests the product list with `sort=price` and `order=asc`
|
||||
- **THEN** products come back ordered by their lowest active SKU price ascending
|
||||
|
||||
#### Scenario: sort by units sold
|
||||
- **WHEN** a shopper requests the product list with `sort=sales` and `order=desc`
|
||||
- **THEN** products come back ordered by their `sold_count` descending, and a product with no paid orders reports zero rather than being omitted
|
||||
|
||||
#### Scenario: filter by brand
|
||||
- **WHEN** a shopper requests products with a `brand_id` alongside a `category_id`
|
||||
- **THEN** only products matching both filters are returned, and `total` reflects the combined filter
|
||||
|
||||
#### Scenario: unsupported sort is rejected
|
||||
- **WHEN** a client requests a `sort` value that is neither `price` nor `sales`
|
||||
- **THEN** the API responds 400 with an `ApiError` body instead of ignoring the parameter
|
||||
|
||||
#### Scenario: unpublished products never appear
|
||||
- **WHEN** any public listing or filter is applied
|
||||
- **THEN** products that are not `published`, or whose shop is not active, are absent from both `items` and `total`
|
||||
@@ -0,0 +1,42 @@
|
||||
## 1. Create `modules/category`
|
||||
|
||||
- [x] 1.1 Create `apps/api/src/modules/category/mod.rs` (mirror `catalog/mod.rs` shape: `mod dto; mod handlers; pub mod service;` + `router()`).
|
||||
- [x] 1.2 Move `Category` struct from `models.rs` into `category/dto.rs` (or a `category/models.rs` if the module needs more than one type later).
|
||||
- [x] 1.3 Move `list_categories` (the `SELECT id, parent_id, name, slug, position FROM categories ORDER BY position, slug` query) from `catalog/service.rs` into `category/service.rs`.
|
||||
- [x] 1.4 Move the `SUBTREE_CTE` constant (`WITH RECURSIVE subtree AS (...)`) into `category` — expose it as `pub(crate) const SUBTREE_CTE` (or a small helper fn) so `product/service.rs` can still build the category-subtree-filtered product query.
|
||||
- [x] 1.5 Move the `GET /categories` route + handler from `catalog/handlers.rs` into `category/handlers.rs`.
|
||||
|
||||
## 2. Create `modules/brand`
|
||||
|
||||
- [x] 2.1 Create `apps/api/src/modules/brand/mod.rs` (same shape as above).
|
||||
- [x] 2.2 Move `Brand` struct from `models.rs` into `brand/dto.rs`.
|
||||
- [x] 2.3 Move `list_brands`, `BrandInput`, `default_active`, and `replace_brands` (including its slug/bilingual-name validation and the transactional delete+reinsert) from `catalog/service.rs` into `brand/service.rs`.
|
||||
- [x] 2.4 Move the `GET /brands` and `PUT /admin/brands` routes + handlers (`list_brands`, `replace_brands`) from `catalog/handlers.rs` into `brand/handlers.rs`.
|
||||
|
||||
## 3. Create `modules/product`
|
||||
|
||||
- [x] 3.1 Create `apps/api/src/modules/product/mod.rs` (same shape as above).
|
||||
- [x] 3.2 Move `product/dto.rs`: `ProductWithSkus`, `PublicListQuery`, `SortBy` (+ `sort_by()`/`ascending()`), `ProductBody`, `SkuBody` from `catalog/dto.rs` unchanged.
|
||||
- [x] 3.3 Move `product/repo.rs`: `attach_skus` and `SoldRow` from `catalog/repo.rs` unchanged (only touches `Product`/`Sku`/order tables, no category/brand coupling).
|
||||
- [x] 3.4 Move `product/service.rs`: `list_public`, `get_public`, `load_own_product`, `list_shop_products`, `get_shop_product`, `validate_product_body`, `create_product`, `update_product`, `transition`, `upsert_sku`, plus the `SOLD_UNITS`, `MIN_PRICE`, `PRODUCT_COLS` constants — import `category`'s `SUBTREE_CTE` instead of redefining it locally.
|
||||
- [x] 3.5 Move `product/handlers.rs`: `/products`, `/products/{id_or_slug}`, `/shop/products`, `/shop/products/{id}`, `/shop/products/{id}/publish`, `/shop/products/{id}/unpublish`, `/shop/products/{id}/skus` routes + handlers, and the `ShopListQuery` struct.
|
||||
- [x] 3.6 Keep `Product` and `Sku` structs in `crate::models` (do not move) — confirmed cross-module dependents in `favorite`, `flash_sale`, `group_buying`.
|
||||
|
||||
## 4. Wire up and remove the old module
|
||||
|
||||
- [x] 4.1 In `apps/api/src/modules/mod.rs`: replace `pub mod catalog;` with `pub mod product; pub mod category; pub mod brand;`, and replace `.merge(catalog::router())` with `.merge(product::router()).merge(category::router()).merge(brand::router())`.
|
||||
- [x] 4.2 Update `apps/api/src/models.rs` to remove the (now-moved) `Category` and `Brand` structs.
|
||||
- [x] 4.3 `rg -n "modules::catalog|catalog::"` across `apps/api/src` and fix any remaining references.
|
||||
- [x] 4.4 Delete `apps/api/src/modules/catalog/`.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
- [x] 5.1 `cargo build` (or `cargo check`) in `apps/api` — zero errors.
|
||||
- [x] 5.2 `cargo clippy` — zero new warnings.
|
||||
- [x] 5.3 `cargo test` — `apps/api/tests/catalog.rs` and the full suite pass unchanged (routes/behavior are identical).
|
||||
- [x] 5.4 Manually diff route list (`rg "\.route\(" apps/api/src/modules/{product,category,brand}`) against the original `catalog/handlers.rs` router to confirm no route was dropped or duplicated.
|
||||
- [x] 5.5 `rg -n "modules/catalog|modules::catalog"` across `docs/` (e.g. `docs/tech-specs/rust-api.md`) and update any references to the old module name.
|
||||
|
||||
## 6. Optional test-file parity
|
||||
|
||||
- [ ] 6.1 (Optional, per design.md open question) Rename/split `apps/api/tests/catalog.rs` into `tests/product.rs`, `tests/category.rs`, `tests/brand.rs` to mirror the new module boundaries — only if desired; not required since these are HTTP-level integration tests unaffected by internal module names.
|
||||
Reference in New Issue
Block a user