feat: 公开首页(搜索+商品详情) + 好心人投稿 + 后台审核收纳
- 公开前端 SPA(根路径 /):首页大搜索框、检索结果、只读商品详情、贡献档案表单 - 公开写入端点 POST /api/public/submissions(无需登录,基础频率限流),投稿进入 submission 待审核队列,不直接写 product - 迁移 0006:submission 投稿表 + community 来源(trust=0.50) - 后台审核队列:列表(待审核/已通过/已驳回) → 查看投稿 → 通过(创建/补全商品 + 记 source=community + 字段级溯源 + 审计 + 重算质量分) / 驳回(记原因) - 公开只读 api 服务内嵌公开 SPA;Dockerfile.prod 增加 node 构建阶段 + 内嵌 dist Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -18,16 +19,23 @@ import (
|
||||
|
||||
// Handler holds the admin dependencies.
|
||||
type Handler struct {
|
||||
store *adminstore.Store
|
||||
authn *auth.Authenticator
|
||||
basePath string
|
||||
spa fs.FS
|
||||
store *adminstore.Store
|
||||
authn *auth.Authenticator
|
||||
basePath string
|
||||
spa fs.FS
|
||||
submitLimit *rateLimiter
|
||||
}
|
||||
|
||||
// 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}
|
||||
return &Handler{
|
||||
store: store,
|
||||
authn: authn,
|
||||
basePath: basePath,
|
||||
spa: spa,
|
||||
submitLimit: newRateLimiter(5, 10*time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
// Router builds the HTTP handler.
|
||||
@@ -56,10 +64,21 @@ func (h *Handler) Router() http.Handler {
|
||||
r.Delete("/api/products/{id}/msrp/{msrpID}", h.DeleteMSRP)
|
||||
r.Get("/api/brands", h.ListBrands)
|
||||
r.Get("/api/categories", h.ListCategories)
|
||||
|
||||
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.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
|
||||
}
|
||||
|
||||
@@ -230,8 +249,91 @@ func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// ---------- 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
|
||||
@@ -240,6 +342,10 @@ func (h *Handler) handleErr(w http.ResponseWriter, err error) bool {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user