Files
goods/api/internal/adminhandler/handler.go
T
sulaimaannaasif6866 916bdd9c7c
CI / Go (api) (pull_request) Successful in 13s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s
feat: 可扩展档案模式框架 + 内置 3C(电子) 模式
- 新增 category.archive_kind 与 kind_field 字段模板表(迁移 0010)
- 种子 electronics 字段模板 + 3C 品类树(手机/笔记本/平板等)
- 后端按档案模式动态计算完整度/合格:食品沿用 food_detail,
  其它模式走 product.attributes + kind_field
- 新增 GET /api/kind-fields?kind= 接口
- 后台编辑页按品类模式动态渲染规格参数表单
- 公开详情页/接口输出带标签的规格表

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 06:13:42 +00:00

747 lines
24 KiB
Go

// 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/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.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)
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")
page, size := pageParams(r)
items, total, err := h.store.ListProducts(r.Context(), q, 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"})
}
// 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}})
}