From 4a693c8fe9eea9bff0f8492a2c79b664d87d023d Mon Sep 17 00:00:00 2001 From: sulaimaannaasif6866 Date: Sun, 21 Jun 2026 00:43:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=88=86=E7=B1=BB=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=88=E5=88=86=E7=B1=BB=E6=A0=91=E5=A2=9E=E5=88=A0?= =?UTF-8?q?=E6=94=B9=E7=A7=BB=20+=20=E5=90=8E=E5=8F=B0=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- admin-frontend/src/App.tsx | 20 +- admin-frontend/src/api.ts | 12 + .../src/components/CategoriesPage.tsx | 311 ++++++++++++++++++ admin-frontend/src/types.ts | 11 + api/internal/adminhandler/handler.go | 71 ++++ api/internal/adminstore/category.go | 297 +++++++++++++++++ api/internal/adminstore/category_test.go | 130 ++++++++ api/internal/adminstore/write.go | 29 +- 8 files changed, 869 insertions(+), 12 deletions(-) create mode 100644 admin-frontend/src/components/CategoriesPage.tsx create mode 100644 api/internal/adminstore/category.go create mode 100644 api/internal/adminstore/category_test.go diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index a418e8c..3aeae20 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -5,9 +5,10 @@ import ProductList from "./components/ProductList"; import ProductDetail from "./components/ProductDetail"; import SubmissionsPage from "./components/SubmissionsPage"; import ApiKeysPage from "./components/ApiKeysPage"; -import { Inbox, KeyRound, LogOut, Package } from "lucide-react"; +import CategoriesPage from "./components/CategoriesPage"; +import { FolderTree, Inbox, KeyRound, LogOut, Package } from "lucide-react"; -type Tab = "products" | "submissions" | "keys"; +type Tab = "products" | "submissions" | "categories" | "keys"; type View = { name: "list" } | { name: "detail"; id: string }; export default function App() { @@ -100,6 +101,19 @@ export default function App() { )} + + + + {error && ( +
{error}
+ )} + + {form && ( + setForm(null)} + onSaved={() => { + setForm(null); + load(); + }} + onError={setError} + /> + )} + +
+ + + + + + + + + + + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((c) => ( + + + + + + + + )) + )} + +
名称路径GPC商品数
+ 暂无分类 +
+ + {c.level > 0 && } + {c.name_zh} + {c.name_en && {c.name_en}} + + + {c.path} + {c.gpc_brick_code || "—"}{c.product_count} + + + +
+
+ + ); +} + +function CategoryForm({ + form, + categories, + onClose, + onSaved, + onError, +}: { + form: FormState; + categories: Category[]; + onClose: () => void; + onSaved: () => void; + onError: (msg: string) => void; +}) { + const [state, setState] = useState(form); + const [busy, setBusy] = useState(false); + + // When editing, the node itself and its descendants are not valid parents. + const parentOptions = useMemo(() => { + if (state.mode === "create") return categories; + const self = categories.find((c) => c.id === state.id); + if (!self) return categories; + return categories.filter( + (c) => c.id !== self.id && !c.path.startsWith(self.path + "."), + ); + }, [categories, state.mode, state.id]); + + function set(key: K, value: FormState[K]) { + setState((s) => ({ ...s, [key]: value })); + } + + async function submit() { + if (!state.name_zh.trim()) { + onError("分类名称不能为空"); + return; + } + setBusy(true); + onError(""); + const body: CategoryInput = { + name_zh: state.name_zh.trim(), + name_en: state.name_en.trim() || null, + parent_id: state.parent_id || null, + gpc_brick_code: state.gpc_brick_code.trim() || null, + }; + if (state.mode === "create") body.slug = state.slug.trim() || null; + try { + if (state.mode === "create") { + await api.createCategory(body); + } else if (state.id) { + await api.updateCategory(state.id, body); + } + onSaved(); + } catch (e) { + onError(e instanceof ApiError ? e.message : "保存失败"); + } finally { + setBusy(false); + } + } + + return ( +
+

+ {state.mode === "create" ? "新建分类" : "编辑分类"} +

+
+ + + + {state.mode === "create" ? ( + + ) : ( + + )} + +
+
+ + +
+
+ ); +} diff --git a/admin-frontend/src/types.ts b/admin-frontend/src/types.ts index 558857a..6ffa6bd 100644 --- a/admin-frontend/src/types.ts +++ b/admin-frontend/src/types.ts @@ -74,6 +74,17 @@ export interface Category { name_en: string | null; path: string; level: number; + parent_id: string | null; + gpc_brick_code: string | null; + product_count: number; +} + +export interface CategoryInput { + name_zh: string; + name_en?: string | null; + slug?: string | null; + parent_id?: string | null; + gpc_brick_code?: string | null; } export interface AuditEntry { diff --git a/api/internal/adminhandler/handler.go b/api/internal/adminhandler/handler.go index 29b6846..547eba6 100644 --- a/api/internal/adminhandler/handler.go +++ b/api/internal/adminhandler/handler.go @@ -77,6 +77,9 @@ func (h *Handler) Router() http.Handler { r.Post("/api/products/{id}/barcodes/{barcodeID}/primary", h.SetPrimaryBarcode) r.Get("/api/brands", h.ListBrands) r.Get("/api/categories", h.ListCategories) + r.Post("/api/categories", h.CreateCategory) + r.Put("/api/categories/{id}", h.UpdateCategory) + r.Delete("/api/categories/{id}", h.DeleteCategory) r.Get("/api/submissions", h.ListSubmissions) r.Get("/api/submissions/{id}", h.GetSubmission) @@ -328,6 +331,74 @@ func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": items}) } +// CreateCategory adds a category node. +func (h *Handler) CreateCategory(w http.ResponseWriter, r *http.Request) { + var in adminstore.CategoryInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + if strings.TrimSpace(in.NameZH) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "分类名称不能为空") + return + } + c, err := h.store.CreateCategory(r.Context(), auth.UserFrom(r.Context()), in) + if h.handleCategoryErr(w, err) { + return + } + writeJSON(w, http.StatusCreated, c) +} + +// UpdateCategory renames and/or moves a category node. +func (h *Handler) UpdateCategory(w http.ResponseWriter, r *http.Request) { + var in adminstore.CategoryInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid body") + return + } + if strings.TrimSpace(in.NameZH) == "" { + writeError(w, http.StatusBadRequest, "bad_request", "分类名称不能为空") + return + } + c, err := h.store.UpdateCategory(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in) + if h.handleCategoryErr(w, err) { + return + } + writeJSON(w, http.StatusOK, c) +} + +// DeleteCategory removes a leaf category that no product uses. +func (h *Handler) DeleteCategory(w http.ResponseWriter, r *http.Request) { + err := h.store.DeleteCategory(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context())) + if h.handleCategoryErr(w, err) { + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// handleCategoryErr maps category-specific errors to client statuses, falling +// back to handleErr otherwise. +func (h *Handler) handleCategoryErr(w http.ResponseWriter, err error) bool { + if err == nil { + return false + } + switch { + case errors.Is(err, adminstore.ErrDuplicatePath): + writeError(w, http.StatusConflict, "duplicate_path", "该分类路径已存在,请换一个英文标识(slug)") + return true + case errors.Is(err, adminstore.ErrCategoryHasChildren): + writeError(w, http.StatusConflict, "has_children", "该分类存在子分类,请先删除或移动其子分类") + return true + case errors.Is(err, adminstore.ErrCategoryInUse): + writeError(w, http.StatusConflict, "in_use", "仍有商品归属于该分类,请先改归其它分类") + return true + case errors.Is(err, adminstore.ErrInvalidParent): + writeError(w, http.StatusBadRequest, "invalid_parent", "上级分类无效(不存在或不能移动到自身/子级下)") + return true + } + return h.handleErr(w, err) +} + // ---------- submissions ---------- // CreateSubmission accepts an anonymous public contribution into the queue. diff --git a/api/internal/adminstore/category.go b/api/internal/adminstore/category.go new file mode 100644 index 0000000..c529f94 --- /dev/null +++ b/api/internal/adminstore/category.go @@ -0,0 +1,297 @@ +package adminstore + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// randomHex returns n random lowercase hex characters for fallback ltree slugs. +func randomHex(n int) string { + b := make([]byte, (n+1)/2) + if _, err := rand.Read(b); err != nil { + return "x" + } + return hex.EncodeToString(b)[:n] +} + +// Category-management errors, mapped to client statuses by the handler. +var ( + // ErrDuplicatePath is returned when a category path already exists. + ErrDuplicatePath = errors.New("duplicate category path") + // ErrCategoryHasChildren blocks deleting a node that still has children. + ErrCategoryHasChildren = errors.New("category has children") + // ErrCategoryInUse blocks deleting a node still referenced by products. + ErrCategoryInUse = errors.New("category in use") + // ErrInvalidParent is returned for a missing parent or an illegal move + // (onto itself or one of its own descendants). + ErrInvalidParent = errors.New("invalid parent category") +) + +// CategoryInput is the payload accepted when creating or editing a category. +// Slug is the ltree label (ASCII); when empty it is derived from NameEN, then +// from a random suffix, since ltree labels cannot contain CJK or spaces. +type CategoryInput struct { + NameZH string `json:"name_zh"` + NameEN *string `json:"name_en"` + Slug *string `json:"slug"` + ParentID *string `json:"parent_id"` + GPCBrickCode *string `json:"gpc_brick_code"` +} + +var slugInvalid = regexp.MustCompile(`[^a-z0-9_]+`) + +// slugify converts a string into a valid ltree label ([a-z0-9_]). +func slugify(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = slugInvalid.ReplaceAllString(s, "_") + s = strings.Trim(s, "_") + for strings.Contains(s, "__") { + s = strings.ReplaceAll(s, "__", "_") + } + return s +} + +// resolveSlug picks an ltree label from the explicit slug, then NameEN, then a +// random fallback so a Chinese-only category still gets a valid path label. +func resolveSlug(in CategoryInput) string { + if in.Slug != nil { + if s := slugify(*in.Slug); s != "" { + return s + } + } + if in.NameEN != nil { + if s := slugify(*in.NameEN); s != "" { + return s + } + } + return "cat_" + randomHex(6) +} + +func trimPtr(p *string) *string { + if p == nil { + return nil + } + t := strings.TrimSpace(*p) + if t == "" { + return nil + } + return &t +} + +// CreateCategory inserts a new category node. With no parent it becomes a root +// (level 0); otherwise its path is parentPath.slug and level is parentLevel+1. +func (s *Store) CreateCategory(ctx context.Context, actor string, in CategoryInput) (*Category, error) { + name := strings.TrimSpace(in.NameZH) + if name == "" { + return nil, errors.New("name_zh required") + } + + parentPath := "" + parentLevel := -1 + var parentID *string + if pid := trimPtr(in.ParentID); pid != nil { + var path string + var level int + err := s.pool.QueryRow(ctx, "SELECT path::text, level FROM category WHERE id = $1", *pid).Scan(&path, &level) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalidParent + } + if err != nil { + return nil, err + } + parentPath, parentLevel, parentID = path, level, pid + } + + slug := resolveSlug(in) + path := slug + if parentPath != "" { + path = parentPath + "." + slug + } + level := parentLevel + 1 + + var c Category + err := s.pool.QueryRow(ctx, ` +INSERT INTO category (name_zh, name_en, parent_id, path, gpc_brick_code, level) +VALUES ($1, $2, $3, $4::ltree, $5, $6) +RETURNING id, name_zh, name_en, path::text, level, parent_id::text, gpc_brick_code, 0`, + name, trimPtr(in.NameEN), parentID, path, trimPtr(in.GPCBrickCode), level). + Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level, &c.ParentID, &c.GPCBrickCode, &c.ProductCount) + if isUniqueViolation(err) { + return nil, ErrDuplicatePath + } + if err != nil { + return nil, err + } + + _ = s.writeAudit(ctx, actor, "create", "category", &c.ID, []string{"name_zh", "path"}, nil, c) + return &c, nil +} + +// UpdateCategory renames a node and/or moves it under a new parent. Moving +// rewrites the path of the node and every descendant via ltree, keeping level +// in sync. Moving a node onto itself or a descendant is rejected. +func (s *Store) UpdateCategory(ctx context.Context, id, actor string, in CategoryInput) (*Category, error) { + name := strings.TrimSpace(in.NameZH) + if name == "" { + return nil, errors.New("name_zh required") + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var oldPath string + var oldLevel int + var oldParent *string + err = tx.QueryRow(ctx, "SELECT path::text, level, parent_id::text FROM category WHERE id = $1", id). + Scan(&oldPath, &oldLevel, &oldParent) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + if _, err := tx.Exec(ctx, + "UPDATE category SET name_zh = $1, name_en = $2, gpc_brick_code = $3 WHERE id = $4", + name, trimPtr(in.NameEN), trimPtr(in.GPCBrickCode), id); err != nil { + return nil, err + } + + newParent := trimPtr(in.ParentID) + if !strEq(newParent, oldParent) { + if err := s.moveCategoryTx(ctx, tx, id, oldPath, newParent); err != nil { + return nil, err + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + out, err := s.getCategory(ctx, id) + if err != nil { + return nil, err + } + _ = s.writeAudit(ctx, actor, "update", "category", &id, []string{"name_zh", "name_en", "gpc_brick_code", "parent_id"}, nil, out) + return out, nil +} + +// moveCategoryTx re-parents a subtree. The node's slug (last path label) is +// preserved; only its ancestor prefix and level change. +func (s *Store) moveCategoryTx(ctx context.Context, tx pgx.Tx, id, oldPath string, newParent *string) error { + slug := oldPath + if i := strings.LastIndex(oldPath, "."); i >= 0 { + slug = oldPath[i+1:] + } + + newBase := slug + if newParent != nil { + var parentPath string + err := tx.QueryRow(ctx, "SELECT path::text FROM category WHERE id = $1", *newParent).Scan(&parentPath) + if errors.Is(err, pgx.ErrNoRows) { + return ErrInvalidParent + } + if err != nil { + return err + } + // Disallow moving a node under itself or one of its descendants. + if parentPath == oldPath || strings.HasPrefix(parentPath, oldPath+".") { + return ErrInvalidParent + } + newBase = parentPath + "." + slug + } + + // Rewrite the node and all descendants in one statement; level tracks depth. + _, err := tx.Exec(ctx, ` +UPDATE category +SET path = ($1::ltree || subpath(path, nlevel($2::ltree) - 1)), + level = nlevel($1::ltree) + (nlevel(path) - nlevel($2::ltree)) - 1 +WHERE path = $2::ltree OR path <@ $2::ltree`, newBase, oldPath) + if isUniqueViolation(err) { + return ErrDuplicatePath + } + if err != nil { + return err + } + + _, err = tx.Exec(ctx, "UPDATE category SET parent_id = $1 WHERE id = $2", newParent, id) + return err +} + +// DeleteCategory removes a leaf node not referenced by any product. Nodes with +// children or in-use nodes are rejected with a specific error. +func (s *Store) DeleteCategory(ctx context.Context, id, actor string) error { + before, err := s.getCategory(ctx, id) + if err != nil { + return err + } + + var children int + if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM category WHERE parent_id = $1", id).Scan(&children); err != nil { + return err + } + if children > 0 { + return ErrCategoryHasChildren + } + + var products int + if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM product WHERE category_id = $1", id).Scan(&products); err != nil { + return err + } + if products > 0 { + return fmt.Errorf("%w: %d products", ErrCategoryInUse, products) + } + + ct, err := s.pool.Exec(ctx, "DELETE FROM category WHERE id = $1", id) + if err != nil { + // A concurrent product assignment can still trip the FK. + if isForeignKeyViolation(err) { + return ErrCategoryInUse + } + return err + } + if ct.RowsAffected() == 0 { + return ErrNotFound + } + + _ = s.writeAudit(ctx, actor, "delete", "category", &id, []string{"path"}, before, nil) + return nil +} + +func (s *Store) getCategory(ctx context.Context, id string) (*Category, error) { + var c Category + err := s.pool.QueryRow(ctx, ` +SELECT c.id, c.name_zh, c.name_en, c.path::text, c.level, c.parent_id::text, + c.gpc_brick_code, + (SELECT count(*) FROM product p WHERE p.category_id = c.id) +FROM category c WHERE c.id = $1`, id). + Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level, &c.ParentID, &c.GPCBrickCode, &c.ProductCount) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &c, nil +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +func isForeignKeyViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23503" +} diff --git a/api/internal/adminstore/category_test.go b/api/internal/adminstore/category_test.go new file mode 100644 index 0000000..54aa979 --- /dev/null +++ b/api/internal/adminstore/category_test.go @@ -0,0 +1,130 @@ +package adminstore + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// newTestStore connects to the test database, skipping when it is unreachable +// or migrations have not been applied. +func newTestStore(t *testing.T) *Store { + t.Helper() + dsn := os.Getenv("OPENGOODS_DATABASE_URL") + if dsn == "" { + dsn = "postgres://opengoods:opengoods@localhost:5432/opengoods?sslmode=disable" + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Skipf("no database: %v", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + t.Skipf("database not reachable: %v", err) + } + var hasTable bool + if err := pool.QueryRow(ctx, "SELECT to_regclass('public.category') IS NOT NULL").Scan(&hasTable); err != nil || !hasTable { + pool.Close() + t.Skip("migrations not applied (category missing)") + } + t.Cleanup(pool.Close) + return New(pool) +} + +func ptr(s string) *string { return &s } + +func TestCategoryLifecycle(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + root, err := s.CreateCategory(ctx, "tester", CategoryInput{ + NameZH: "测试根", Slug: ptr("test_root_" + randomHex(6)), + }) + if err != nil { + t.Fatalf("create root: %v", err) + } + t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM category WHERE path <@ $1::ltree", root.Path) }) + + if root.Level != 0 || root.ParentID != nil { + t.Fatalf("root level/parent wrong: level=%d parent=%v", root.Level, root.ParentID) + } + + child, err := s.CreateCategory(ctx, "tester", CategoryInput{ + NameZH: "测试子", NameEN: ptr("Test Child"), ParentID: &root.ID, + }) + if err != nil { + t.Fatalf("create child: %v", err) + } + if child.Level != 1 || child.ParentID == nil || *child.ParentID != root.ID { + t.Fatalf("child hierarchy wrong: %+v", child) + } + + // Deleting a node with children must fail. + if err := s.DeleteCategory(ctx, root.ID, "tester"); !errors.Is(err, ErrCategoryHasChildren) { + t.Fatalf("expected ErrCategoryHasChildren, got %v", err) + } + + // Rename child. + renamed, err := s.UpdateCategory(ctx, child.ID, "tester", CategoryInput{NameZH: "测试子-改名"}) + if err != nil { + t.Fatalf("rename: %v", err) + } + if renamed.NameZH != "测试子-改名" { + t.Fatalf("rename not applied: %q", renamed.NameZH) + } + + // Move child to a second root, descendants' path/level should follow. + root2, err := s.CreateCategory(ctx, "tester", CategoryInput{ + NameZH: "测试根2", Slug: ptr("test_root2_" + randomHex(6)), + }) + if err != nil { + t.Fatalf("create root2: %v", err) + } + t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM category WHERE path <@ $1::ltree", root2.Path) }) + + moved, err := s.UpdateCategory(ctx, child.ID, "tester", CategoryInput{NameZH: "测试子-改名", ParentID: &root2.ID}) + if err != nil { + t.Fatalf("move: %v", err) + } + if moved.ParentID == nil || *moved.ParentID != root2.ID { + t.Fatalf("move parent wrong: %+v", moved) + } + if moved.Level != 1 { + t.Fatalf("moved level wrong: %d", moved.Level) + } + + // Moving a node under itself must be rejected. + if _, err := s.UpdateCategory(ctx, root2.ID, "tester", CategoryInput{NameZH: "测试根2", ParentID: &child.ID}); !errors.Is(err, ErrInvalidParent) { + t.Fatalf("expected ErrInvalidParent for self-move, got %v", err) + } + + // Duplicate path on create must be rejected. + if _, err := s.CreateCategory(ctx, "tester", CategoryInput{NameZH: "dup", Slug: ptr(root.Path)}); !errors.Is(err, ErrDuplicatePath) { + t.Fatalf("expected ErrDuplicatePath, got %v", err) + } + + // Now the leaf can be deleted. + if err := s.DeleteCategory(ctx, child.ID, "tester"); err != nil { + t.Fatalf("delete leaf: %v", err) + } +} + +func TestSlugify(t *testing.T) { + cases := map[string]string{ + "Cooking Oil": "cooking_oil", + " Hello--Wld": "hello_wld", + "食品": "", + "a__b": "a_b", + } + for in, want := range cases { + if got := slugify(in); got != want { + t.Errorf("slugify(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/api/internal/adminstore/write.go b/api/internal/adminstore/write.go index 466582d..238cdef 100644 --- a/api/internal/adminstore/write.go +++ b/api/internal/adminstore/write.go @@ -354,19 +354,27 @@ func (s *Store) ListBrands(ctx context.Context) ([]Brand, error) { return out, rows.Err() } -// Category is a category option for the edit form. +// Category is a category option for the edit form and the management view. type Category struct { - ID string `json:"id"` - NameZH string `json:"name_zh"` - NameEN *string `json:"name_en"` - Path string `json:"path"` - Level int `json:"level"` + ID string `json:"id"` + NameZH string `json:"name_zh"` + NameEN *string `json:"name_en"` + Path string `json:"path"` + Level int `json:"level"` + ParentID *string `json:"parent_id"` + GPCBrickCode *string `json:"gpc_brick_code"` + ProductCount int `json:"product_count"` } -// ListCategories returns the full category tree. +// ListCategories returns the full category tree (path order) with the number of +// products directly assigned to each node. func (s *Store) ListCategories(ctx context.Context) ([]Category, error) { - rows, err := s.pool.Query(ctx, - "SELECT id, name_zh, name_en, path::text, level FROM category ORDER BY path") + rows, err := s.pool.Query(ctx, ` +SELECT c.id, c.name_zh, c.name_en, c.path::text, c.level, c.parent_id::text, + c.gpc_brick_code, + (SELECT count(*) FROM product p WHERE p.category_id = c.id) AS product_count +FROM category c +ORDER BY c.path`) if err != nil { return nil, err } @@ -374,7 +382,8 @@ func (s *Store) ListCategories(ctx context.Context) ([]Category, error) { out := []Category{} for rows.Next() { var c Category - if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level); err != nil { + if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.Level, + &c.ParentID, &c.GPCBrickCode, &c.ProductCount); err != nil { return nil, err } out = append(out, c)