feat(admin): 运营后台(登录/查看/审核编辑/补全)+ 写入API + 审计留痕
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
// 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"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Handler holds the admin dependencies.
|
||||
type Handler struct {
|
||||
store *adminstore.Store
|
||||
authn *auth.Authenticator
|
||||
basePath string
|
||||
spa fs.FS
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
// 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/products", h.ListProducts)
|
||||
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.Get("/api/brands", h.ListBrands)
|
||||
r.Get("/api/categories", h.ListCategories)
|
||||
})
|
||||
|
||||
r.Handle("/*", http.HandlerFunc(h.serveSPA))
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
// 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"})
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
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
|
||||
}
|
||||
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}})
|
||||
}
|
||||
Reference in New Issue
Block a user