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:
@@ -7,8 +7,10 @@ package handler
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -27,11 +29,12 @@ const (
|
||||
// Handler holds dependencies shared by the HTTP routes.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
spa fs.FS
|
||||
}
|
||||
|
||||
// New constructs a Handler backed by the given store.
|
||||
func New(s *store.Store) *Handler {
|
||||
return &Handler{store: s}
|
||||
// New constructs a Handler backed by the given store. spa may be nil (JSON-only).
|
||||
func New(s *store.Store, spa fs.FS) *Handler {
|
||||
return &Handler{store: s, spa: spa}
|
||||
}
|
||||
|
||||
// Router builds the top-level HTTP handler with middleware and routes mounted.
|
||||
@@ -56,9 +59,35 @@ func (h *Handler) Router() http.Handler {
|
||||
r.Get("/sources/{id}", h.SourceByID)
|
||||
})
|
||||
|
||||
// Public SPA (homepage + search + contribute). API routes above take
|
||||
// precedence; everything else falls back to the embedded single-page app.
|
||||
if h.spa != nil {
|
||||
r.Handle("/*", http.HandlerFunc(h.serveSPA))
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *Handler) serveSPA(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if rel == "" {
|
||||
rel = "index.html"
|
||||
}
|
||||
if f, err := h.spa.Open(rel); err == nil {
|
||||
f.Close()
|
||||
http.FileServer(http.FS(h.spa)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// SPA fallback: serve index.html for client-side routes.
|
||||
data, err := fs.ReadFile(h.spa, "index.html")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// Healthz reports liveness of the service.
|
||||
func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
|
||||
@@ -61,7 +61,7 @@ func newTestHandler(t *testing.T) (*Handler, string) {
|
||||
_, _ = pool.Exec(context.Background(), "DELETE FROM product WHERE gtin=$1", gtin)
|
||||
pool.Close()
|
||||
})
|
||||
return New(store.New(pool)), gtin
|
||||
return New(store.New(pool), nil), gtin
|
||||
}
|
||||
|
||||
func doGET(t *testing.T, h *Handler, path string) *httptest.ResponseRecorder {
|
||||
|
||||
@@ -11,7 +11,7 @@ func TestHealthz(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
New(nil).Router().ServeHTTP(rec, req)
|
||||
New(nil, nil).Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||
|
||||
Reference in New Issue
Block a user