// Package adminhandler wires up the authenticated admin console: a JSON write // API mounted under a base path (default /ping) plus the embedded SPA. package adminhandler import ( "encoding/json" "errors" "io/fs" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/baicai2026-baicai/goods/api/internal/adminstore" "github.com/baicai2026-baicai/goods/api/internal/apikey" "github.com/baicai2026-baicai/goods/api/internal/auth" "github.com/baicai2026-baicai/goods/api/internal/gtin" "github.com/baicai2026-baicai/goods/api/internal/ratelimit" ) // Handler holds the admin dependencies. type Handler struct { store *adminstore.Store authn *auth.Authenticator basePath string spa fs.FS submitLimit *rateLimiter usage *ratelimit.Limiter } // New constructs an admin Handler. basePath is e.g. "/ping" (no trailing slash). func New(store *adminstore.Store, authn *auth.Authenticator, basePath string, spa fs.FS) *Handler { basePath = "/" + strings.Trim(basePath, "/") return &Handler{ store: store, authn: authn, basePath: basePath, spa: spa, submitLimit: newRateLimiter(5, 10*time.Minute), } } // WithUsage attaches a Redis-backed limiter used to read per-key usage counters // for the API-key management view. Optional; without it usage shows as zero. func (h *Handler) WithUsage(l *ratelimit.Limiter) *Handler { h.usage = l return h } // Router builds the HTTP handler. func (h *Handler) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Recoverer) r.Route(h.basePath, func(r chi.Router) { r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) r.Post("/api/login", h.Login) r.Group(func(r chi.Router) { r.Use(h.authn.Middleware) r.Get("/api/me", h.Me) r.Get("/api/stats", h.Stats) r.Get("/api/audit", h.ListAllAudit) r.Get("/api/products", h.ListProducts) r.Post("/api/products", h.CreateProduct) r.Post("/api/products/bulk", h.BulkProducts) r.Get("/api/products/{id}", h.GetProduct) r.Put("/api/products/{id}", h.UpdateProduct) r.Get("/api/products/{id}/audit", h.ListAudit) r.Post("/api/products/{id}/images", h.AddImage) r.Delete("/api/products/{id}/images/{imageID}", h.DeleteImage) r.Post("/api/products/{id}/msrp", h.AddMSRP) r.Delete("/api/products/{id}/msrp/{msrpID}", h.DeleteMSRP) r.Post("/api/products/{id}/barcodes", h.AddBarcode) r.Delete("/api/products/{id}/barcodes/{barcodeID}", h.DeleteBarcode) r.Post("/api/products/{id}/barcodes/{barcodeID}/primary", h.SetPrimaryBarcode) r.Get("/api/brands", h.ListBrands) r.Post("/api/brands", h.CreateBrand) r.Put("/api/brands/{id}", h.UpdateBrand) r.Post("/api/brands/{id}/merge", h.MergeBrands) r.Delete("/api/brands/{id}", h.DeleteBrand) r.Get("/api/kind-fields", h.ListKindFields) 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.Post("/api/import/bypos", h.ImportBypos) r.Get("/api/submissions", h.ListSubmissions) r.Get("/api/submissions/{id}", h.GetSubmission) r.Post("/api/submissions/{id}/approve", h.ApproveSubmission) r.Post("/api/submissions/{id}/reject", h.RejectSubmission) r.Get("/api/keys", h.ListAPIKeys) r.Post("/api/keys", h.CreateAPIKey) r.Delete("/api/keys/{id}", h.RevokeAPIKey) }) r.Handle("/*", http.HandlerFunc(h.serveSPA)) }) // Public, unauthenticated contribution endpoint (proxied at /api/public/*). // Submissions enter a moderation queue and never touch products until an // admin approves them. r.Post("/api/public/submissions", h.CreateSubmission) // Archive backflow: inventory-management software pushes products missing // from the archive. Authenticated with a public API key; records enter the // same moderation queue and are archived only after admin approval. r.Post("/api/public/backflow", h.Backflow) return r } func (h *Handler) serveSPA(w http.ResponseWriter, r *http.Request) { rel := strings.TrimPrefix(r.URL.Path, h.basePath) rel = strings.TrimPrefix(rel, "/") if rel == "" { rel = "index.html" } if f, err := h.spa.Open(rel); err == nil { f.Close() http.StripPrefix(h.basePath+"/", http.FileServer(http.FS(h.spa))).ServeHTTP(w, r) return } // SPA fallback: serve index.html for client-side routes. index, err := h.spa.Open("index.html") if err != nil { http.NotFound(w, r) return } defer index.Close() data, _ := fs.ReadFile(h.spa, "index.html") w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(data) } // ---------- auth ---------- // Login authenticates and returns a bearer token. func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { var body struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid body") return } token, err := h.authn.Login(body.Username, body.Password) if err != nil { writeError(w, http.StatusUnauthorized, "unauthorized", "用户名或密码错误") return } writeJSON(w, http.StatusOK, map[string]string{"token": token, "username": body.Username}) } // Me returns the current authenticated user. func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"username": auth.UserFrom(r.Context())}) } // ---------- products ---------- // ListProducts returns a paginated product list. func (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") sort := r.URL.Query().Get("sort") order := r.URL.Query().Get("order") page, size := pageParams(r) items, total, err := h.store.ListProducts(r.Context(), q, sort, order, size, (page-1)*size) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]any{ "items": items, "page": page, "size": size, "total": total, "completeness_fields": adminstore.CompletenessFields, }) } // GetProduct returns full editable detail. func (h *Handler) GetProduct(w http.ResponseWriter, r *http.Request) { d, err := h.store.GetProduct(r.Context(), chi.URLParam(r, "id")) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, d) } // CreateProduct adds a new product with core fields; the rest is filled in via // the detail editor. func (h *Handler) CreateProduct(w http.ResponseWriter, r *http.Request) { var in adminstore.ProductInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid body") return } if strings.TrimSpace(in.Name) == "" { writeError(w, http.StatusBadRequest, "bad_request", "名称不能为空") return } d, err := h.store.CreateProduct(r.Context(), auth.UserFrom(r.Context()), in) if errors.Is(err, adminstore.ErrDuplicateGTIN) { writeError(w, http.StatusConflict, "duplicate_gtin", "该条码(GTIN)已被其它商品使用") return } if h.handleErr(w, err) { return } writeJSON(w, http.StatusCreated, d) } // Stats returns the dashboard overview counters. func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) { st, err := h.store.Stats(r.Context()) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, st) } // ListAllAudit returns a page of the global operations audit log. func (h *Handler) ListAllAudit(w http.ResponseWriter, r *http.Request) { page, size := pageParams(r) items, total, err := h.store.ListAllAudit(r.Context(), size, (page-1)*size) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]any{ "items": items, "page": page, "size": size, "total": total, }) } type bulkInput struct { IDs []string `json:"ids"` Action string `json:"action"` Status string `json:"status"` CategoryID *string `json:"category_id"` } // BulkProducts applies a status or category change to many products at once. func (h *Handler) BulkProducts(w http.ResponseWriter, r *http.Request) { var in bulkInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid body") return } if len(in.IDs) == 0 { writeError(w, http.StatusBadRequest, "bad_request", "未选择任何商品") return } actor := auth.UserFrom(r.Context()) var ( affected int err error ) switch in.Action { case "status": affected, err = h.store.BulkSetStatus(r.Context(), actor, in.IDs, in.Status) case "category": affected, err = h.store.BulkSetCategory(r.Context(), actor, in.IDs, in.CategoryID) default: writeError(w, http.StatusBadRequest, "bad_request", "未知的批量操作") return } switch { case errors.Is(err, adminstore.ErrInvalidStatus): writeError(w, http.StatusBadRequest, "invalid_status", "无效的状态值") return case errors.Is(err, adminstore.ErrInvalidParent): writeError(w, http.StatusBadRequest, "invalid_parent", "目标分类无效") return case errors.Is(err, adminstore.ErrNoTargets): writeError(w, http.StatusBadRequest, "bad_request", "未选择任何商品") return } if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "affected": affected}) } // UpdateProduct applies an edit. func (h *Handler) UpdateProduct(w http.ResponseWriter, r *http.Request) { var in adminstore.ProductInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid body") return } if strings.TrimSpace(in.Name) == "" { writeError(w, http.StatusBadRequest, "bad_request", "名称不能为空") return } d, err := h.store.UpdateProduct(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, d) } // ListAudit returns audit history for a product. func (h *Handler) ListAudit(w http.ResponseWriter, r *http.Request) { items, err := h.store.ListAudit(r.Context(), chi.URLParam(r, "id"), 100) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]any{"items": items}) } // AddImage adds an image URL. func (h *Handler) AddImage(w http.ResponseWriter, r *http.Request) { var body struct { URL string `json:"url"` Kind string `json:"kind"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.URL) == "" { writeError(w, http.StatusBadRequest, "bad_request", "图片 URL 不能为空") return } im, err := h.store.AddImage(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), body.URL, body.Kind) if h.handleErr(w, err) { return } writeJSON(w, http.StatusCreated, im) } // DeleteImage removes an image. func (h *Handler) DeleteImage(w http.ResponseWriter, r *http.Request) { err := h.store.DeleteImage(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "imageID"), auth.UserFrom(r.Context())) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // AddMSRP adds a suggested-retail-price snapshot. func (h *Handler) AddMSRP(w http.ResponseWriter, r *http.Request) { var in adminstore.MSRPInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid body") return } m, err := h.store.AddMSRP(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in) if h.handleErr(w, err) { return } writeJSON(w, http.StatusCreated, m) } // DeleteMSRP removes an MSRP snapshot. func (h *Handler) DeleteMSRP(w http.ResponseWriter, r *http.Request) { err := h.store.DeleteMSRP(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "msrpID"), auth.UserFrom(r.Context())) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // ---------- barcodes ---------- // AddBarcode validates and attaches a barcode to a product. A code already // owned by another product yields 409 with the conflicting product so the // operator can de-duplicate; an invalid GTIN yields 400. func (h *Handler) AddBarcode(w http.ResponseWriter, r *http.Request) { var in adminstore.BarcodeInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid body") return } b, err := h.store.AddBarcode(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in) if h.handleBarcodeErr(w, err) { return } writeJSON(w, http.StatusCreated, b) } // DeleteBarcode removes a barcode; a primary one is replaced automatically. func (h *Handler) DeleteBarcode(w http.ResponseWriter, r *http.Request) { err := h.store.DeleteBarcode(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "barcodeID"), auth.UserFrom(r.Context())) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // SetPrimaryBarcode marks one barcode primary and mirrors it to product.gtin. func (h *Handler) SetPrimaryBarcode(w http.ResponseWriter, r *http.Request) { b, err := h.store.SetPrimaryBarcode(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "barcodeID"), auth.UserFrom(r.Context())) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, b) } // handleBarcodeErr maps barcode-specific errors (GTIN validation, ownership // conflict) to client-facing statuses, falling back to handleErr otherwise. func (h *Handler) handleBarcodeErr(w http.ResponseWriter, err error) bool { if err == nil { return false } var conflict *adminstore.ConflictError if errors.As(err, &conflict) { writeJSON(w, http.StatusConflict, map[string]any{ "error": map[string]string{"code": "barcode_conflict", "message": err.Error()}, "conflict": map[string]string{ "gtin": conflict.GTIN, "product_id": conflict.ProductID, "product_name": conflict.ProductName, }, }) return true } if errors.Is(err, gtin.ErrEmpty) || errors.Is(err, gtin.ErrFormat) || errors.Is(err, gtin.ErrCheck) || errors.Is(err, gtin.ErrRestricted) { writeError(w, http.StatusBadRequest, "invalid_gtin", err.Error()) return true } return h.handleErr(w, err) } // ListBrands returns brand options. func (h *Handler) ListBrands(w http.ResponseWriter, r *http.Request) { items, err := h.store.ListBrands(r.Context()) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]any{"items": items}) } // ListKindFields returns the editable spec field template for an archive kind. func (h *Handler) ListKindFields(w http.ResponseWriter, r *http.Request) { kind := strings.TrimSpace(r.URL.Query().Get("kind")) if kind == "" { writeError(w, http.StatusBadRequest, "bad_request", "缺少 kind 参数") return } items, err := h.store.ListKindFields(r.Context(), kind) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]any{"items": items, "kind": kind}) } // ListCategories returns category options. func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) { items, err := h.store.ListCategories(r.Context()) if h.handleErr(w, err) { return } 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) } type brandInput struct { Name string `json:"name"` } type brandMergeInput struct { TargetID string `json:"target_id"` } // CreateBrand adds a brand. func (h *Handler) CreateBrand(w http.ResponseWriter, r *http.Request) { var in brandInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid body") return } if strings.TrimSpace(in.Name) == "" { writeError(w, http.StatusBadRequest, "bad_request", "品牌名称不能为空") return } b, err := h.store.CreateBrand(r.Context(), auth.UserFrom(r.Context()), in.Name) if h.handleBrandErr(w, err) { return } writeJSON(w, http.StatusCreated, b) } // UpdateBrand renames a brand. func (h *Handler) UpdateBrand(w http.ResponseWriter, r *http.Request) { var in brandInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid body") return } if strings.TrimSpace(in.Name) == "" { writeError(w, http.StatusBadRequest, "bad_request", "品牌名称不能为空") return } b, err := h.store.UpdateBrand(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), in.Name) if h.handleBrandErr(w, err) { return } writeJSON(w, http.StatusOK, b) } // MergeBrands folds one brand's products into another, then deletes the source. func (h *Handler) MergeBrands(w http.ResponseWriter, r *http.Request) { var in brandMergeInput if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid body") return } if strings.TrimSpace(in.TargetID) == "" { writeError(w, http.StatusBadRequest, "bad_request", "请选择合并目标品牌") return } b, err := h.store.MergeBrands(r.Context(), chi.URLParam(r, "id"), in.TargetID, auth.UserFrom(r.Context())) if h.handleBrandErr(w, err) { return } writeJSON(w, http.StatusOK, b) } // DeleteBrand removes a brand no product references. func (h *Handler) DeleteBrand(w http.ResponseWriter, r *http.Request) { err := h.store.DeleteBrand(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context())) if h.handleBrandErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // handleBrandErr maps brand-specific errors to client statuses. func (h *Handler) handleBrandErr(w http.ResponseWriter, err error) bool { if err == nil { return false } switch { case errors.Is(err, adminstore.ErrDuplicateBrand): writeError(w, http.StatusConflict, "duplicate_brand", "该品牌名称已存在") return true case errors.Is(err, adminstore.ErrBrandInUse): writeError(w, http.StatusConflict, "in_use", "仍有商品使用该品牌,请先改用其它品牌或合并") return true case errors.Is(err, adminstore.ErrInvalidMerge): writeError(w, http.StatusBadRequest, "invalid_merge", "合并目标无效(不存在或与源品牌相同)") return true } return h.handleErr(w, err) } // ---------- submissions ---------- // CreateSubmission accepts an anonymous public contribution into the queue. func (h *Handler) CreateSubmission(w http.ResponseWriter, r *http.Request) { if !h.submitLimit.allow(realIP(r)) { writeError(w, http.StatusTooManyRequests, "rate_limited", "提交过于频繁,请稍后再试") return } var in adminstore.SubmissionInput if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid body") return } if strings.TrimSpace(in.Name) == "" { writeError(w, http.StatusBadRequest, "bad_request", "商品名称不能为空") return } id, err := h.store.CreateSubmission(r.Context(), in, realIP(r)) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) return } writeJSON(w, http.StatusCreated, map[string]string{"id": id, "status": "pending"}) } // Backflow accepts a batch of products pushed by inventory-management software. // It authenticates with a public API key (X-API-Key or Bearer), enqueues each // item for admin review (deduplicating by GTIN), and returns a per-item summary. func (h *Handler) Backflow(w http.ResponseWriter, r *http.Request) { raw := presentedAPIKey(r) if raw == "" { writeError(w, http.StatusUnauthorized, "missing_api_key", "缺少 API key(请在 X-API-Key 或 Authorization: Bearer 中提供)") return } if !apikey.IsWellFormed(raw) { writeError(w, http.StatusUnauthorized, "invalid_api_key", "API key 格式无效") return } if _, err := h.store.APIKeyByHash(r.Context(), apikey.Hash(raw)); err != nil { if errors.Is(err, adminstore.ErrNotFound) { writeError(w, http.StatusUnauthorized, "invalid_api_key", "API key 无效或已吊销") return } writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) return } var items []adminstore.SubmissionInput if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<20)).Decode(&items); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "请求体应为商品数组 (JSON array)") return } if len(items) == 0 { writeError(w, http.StatusBadRequest, "bad_request", "回流列表为空") return } if len(items) > 1000 { writeError(w, http.StatusBadRequest, "too_many", "单次回流最多 1000 条") return } sum, err := h.store.CreateBackflowSubmissions(r.Context(), items, realIP(r)) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) return } writeJSON(w, http.StatusOK, sum) } // presentedAPIKey extracts a public API key from X-API-Key or a Bearer token. func presentedAPIKey(r *http.Request) string { if v := strings.TrimSpace(r.Header.Get("X-API-Key")); v != "" { return v } if v := r.Header.Get("Authorization"); strings.HasPrefix(v, "Bearer ") { return strings.TrimSpace(strings.TrimPrefix(v, "Bearer ")) } return "" } // ListSubmissions returns the moderation queue (admin). func (h *Handler) ListSubmissions(w http.ResponseWriter, r *http.Request) { status := r.URL.Query().Get("status") page, size := pageParams(r) items, total, err := h.store.ListSubmissions(r.Context(), status, size, (page-1)*size) if h.handleErr(w, err) { return } pending, _ := h.store.PendingSubmissionCount(r.Context()) writeJSON(w, http.StatusOK, map[string]any{ "items": items, "page": page, "size": size, "total": total, "pending": pending, }) } // GetSubmission returns full submission detail (admin). func (h *Handler) GetSubmission(w http.ResponseWriter, r *http.Request) { d, err := h.store.GetSubmission(r.Context(), chi.URLParam(r, "id")) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, d) } // ApproveSubmission applies a contribution to the product store (admin). func (h *Handler) ApproveSubmission(w http.ResponseWriter, r *http.Request) { d, err := h.store.ApproveSubmission(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context())) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, d) } // RejectSubmission rejects a contribution with a reviewer note (admin). func (h *Handler) RejectSubmission(w http.ResponseWriter, r *http.Request) { var body struct { Note string `json:"note"` } _ = json.NewDecoder(r.Body).Decode(&body) err := h.store.RejectSubmission(r.Context(), chi.URLParam(r, "id"), auth.UserFrom(r.Context()), body.Note) if h.handleErr(w, err) { return } writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"}) } // ---------- helpers ---------- func realIP(r *http.Request) string { if ip := r.Header.Get("X-Forwarded-For"); ip != "" { if i := strings.IndexByte(ip, ','); i >= 0 { return strings.TrimSpace(ip[:i]) } return strings.TrimSpace(ip) } if ip := r.Header.Get("X-Real-IP"); ip != "" { return ip } return r.RemoteAddr } func (h *Handler) handleErr(w http.ResponseWriter, err error) bool { if err == nil { return false } if errors.Is(err, adminstore.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "资源不存在") return true } if errors.Is(err, adminstore.ErrConflict) { writeError(w, http.StatusConflict, "conflict", "该投稿已被处理") return true } writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) return true } func pageParams(r *http.Request) (page, size int) { page = atoiDefault(r.URL.Query().Get("page"), 1) if page < 1 { page = 1 } size = atoiDefault(r.URL.Query().Get("size"), 20) if size < 1 { size = 20 } if size > 100 { size = 100 } return page, size } func atoiDefault(s string, fallback int) int { if s == "" { return fallback } n := 0 for _, c := range s { if c < '0' || c > '9' { return fallback } n = n*10 + int(c-'0') } return n } func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } func writeError(w http.ResponseWriter, status int, code, message string) { writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}}) }