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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package adminhandler
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter is a simple fixed-window per-key limiter used to throttle
|
||||
// anonymous public submissions (basic anti-spam; captcha can be added later).
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
limit int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
|
||||
return &rateLimiter{hits: map[string][]time.Time{}, limit: limit, window: window}
|
||||
}
|
||||
|
||||
// allow reports whether the key may proceed, recording the hit if so.
|
||||
func (r *rateLimiter) allow(key string) bool {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-r.window)
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
kept := r.hits[key][:0]
|
||||
for _, t := range r.hits[key] {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= r.limit {
|
||||
r.hits[key] = kept
|
||||
return false
|
||||
}
|
||||
r.hits[key] = append(kept, now)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package adminstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrConflict is returned when a submission has already been reviewed.
|
||||
var ErrConflict = errors.New("conflict")
|
||||
|
||||
// SubmissionImage is one proposed image URL inside a contribution.
|
||||
type SubmissionImage struct {
|
||||
URL string `json:"url"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// SubmissionInput is the public contribution payload (no login required).
|
||||
type SubmissionInput struct {
|
||||
GTIN *string `json:"gtin"`
|
||||
Name string `json:"name"`
|
||||
BrandName *string `json:"brand_name"`
|
||||
CategoryID *string `json:"category_id"`
|
||||
NetContentValue *float64 `json:"net_content_value"`
|
||||
NetContentUnit *string `json:"net_content_unit"`
|
||||
CountryOfOrigin *string `json:"country_of_origin"`
|
||||
IngredientsText *string `json:"ingredients_text"`
|
||||
Nutriments map[string]any `json:"nutriments"`
|
||||
NutritionBasis *string `json:"nutrition_basis"`
|
||||
ServingSize *string `json:"serving_size"`
|
||||
NutriScore *string `json:"nutri_score"`
|
||||
Images []SubmissionImage `json:"images"`
|
||||
MSRP []MSRPInput `json:"msrp"`
|
||||
SubmitterName *string `json:"submitter_name"`
|
||||
SubmitterContact *string `json:"submitter_contact"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
// SubmissionRow is a queue-list row for the admin review table.
|
||||
type SubmissionRow struct {
|
||||
ID string `json:"id"`
|
||||
GTIN *string `json:"gtin"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
SubmitterName *string `json:"submitter_name"`
|
||||
Matched bool `json:"matched"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ReviewedAt *string `json:"reviewed_at"`
|
||||
}
|
||||
|
||||
// SubmissionDetail is the full review view of one contribution.
|
||||
type SubmissionDetail struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
GTIN *string `json:"gtin"`
|
||||
Name string `json:"name"`
|
||||
SubmitterName *string `json:"submitter_name"`
|
||||
SubmitterContact *string `json:"submitter_contact"`
|
||||
Note *string `json:"note"`
|
||||
ReviewNote *string `json:"review_note"`
|
||||
ReviewedBy *string `json:"reviewed_by"`
|
||||
ReviewedAt *string `json:"reviewed_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
TargetProductID *string `json:"target_product_id"`
|
||||
ResultProductID *string `json:"result_product_id"`
|
||||
Payload SubmissionInput `json:"payload"`
|
||||
ExistingProduct *ProductDetail `json:"existing_product,omitempty"`
|
||||
}
|
||||
|
||||
// CreateSubmission validates and stores a public contribution as pending.
|
||||
func (s *Store) CreateSubmission(ctx context.Context, in SubmissionInput, remoteIP string) (string, error) {
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
if in.Name == "" {
|
||||
return "", errors.New("商品名称不能为空")
|
||||
}
|
||||
if in.GTIN != nil {
|
||||
g := strings.TrimSpace(*in.GTIN)
|
||||
if g == "" {
|
||||
in.GTIN = nil
|
||||
} else {
|
||||
in.GTIN = &g
|
||||
}
|
||||
}
|
||||
|
||||
// Link to an existing product when the barcode already exists (supplement).
|
||||
var target *string
|
||||
if in.GTIN != nil {
|
||||
var pid string
|
||||
err := s.pool.QueryRow(ctx, "SELECT id FROM product WHERE gtin = $1", *in.GTIN).Scan(&pid)
|
||||
if err == nil {
|
||||
target = &pid
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var id string
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
INSERT INTO submission (gtin, name, payload, target_product_id, submitter_name, submitter_contact, note, remote_ip)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
|
||||
in.GTIN, in.Name, payload, target, in.SubmitterName, in.SubmitterContact, in.Note, remoteIP).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// ListSubmissions returns submissions filtered by status (empty = all).
|
||||
func (s *Store) ListSubmissions(ctx context.Context, status string, limit, offset int) ([]SubmissionRow, int, error) {
|
||||
args := []any{}
|
||||
where := "WHERE 1=1"
|
||||
if status != "" {
|
||||
args = append(args, status)
|
||||
where += " AND status = $1"
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM submission "+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
args = append(args, limit, offset)
|
||||
sql := `
|
||||
SELECT id, gtin, name, status, submitter_name, (target_product_id IS NOT NULL),
|
||||
created_at, reviewed_at
|
||||
FROM submission ` + where +
|
||||
" ORDER BY (status='pending') DESC, created_at DESC LIMIT $" +
|
||||
strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args))
|
||||
|
||||
rows, err := s.pool.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []SubmissionRow{}
|
||||
for rows.Next() {
|
||||
var r SubmissionRow
|
||||
var created time.Time
|
||||
var reviewed *time.Time
|
||||
if err := rows.Scan(&r.ID, &r.GTIN, &r.Name, &r.Status, &r.SubmitterName, &r.Matched, &created, &reviewed); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
r.CreatedAt = created.Format(time.RFC3339)
|
||||
if reviewed != nil {
|
||||
t := reviewed.Format(time.RFC3339)
|
||||
r.ReviewedAt = &t
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// PendingSubmissionCount returns the number of submissions awaiting review.
|
||||
func (s *Store) PendingSubmissionCount(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.pool.QueryRow(ctx, "SELECT count(*) FROM submission WHERE status='pending'").Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// GetSubmission returns the full review detail for one submission.
|
||||
func (s *Store) GetSubmission(ctx context.Context, id string) (*SubmissionDetail, error) {
|
||||
var d SubmissionDetail
|
||||
var payload []byte
|
||||
var created time.Time
|
||||
var reviewed *time.Time
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, status, gtin, name, submitter_name, submitter_contact, note,
|
||||
review_note, reviewed_by, reviewed_at, created_at, target_product_id, result_product_id, payload
|
||||
FROM submission WHERE id = $1`, id).Scan(
|
||||
&d.ID, &d.Status, &d.GTIN, &d.Name, &d.SubmitterName, &d.SubmitterContact, &d.Note,
|
||||
&d.ReviewNote, &d.ReviewedBy, &reviewed, &created, &d.TargetProductID, &d.ResultProductID, &payload,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.CreatedAt = created.Format(time.RFC3339)
|
||||
if reviewed != nil {
|
||||
t := reviewed.Format(time.RFC3339)
|
||||
d.ReviewedAt = &t
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
_ = json.Unmarshal(payload, &d.Payload)
|
||||
}
|
||||
if d.TargetProductID != nil {
|
||||
if ep, err := s.GetProduct(ctx, *d.TargetProductID); err == nil {
|
||||
d.ExistingProduct = ep
|
||||
}
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// RejectSubmission marks a pending submission as rejected with a reviewer note.
|
||||
func (s *Store) RejectSubmission(ctx context.Context, id, actor, note string) error {
|
||||
ct, err := s.pool.Exec(ctx, `
|
||||
UPDATE submission SET status='rejected', review_note=$2, reviewed_by=$3, reviewed_at=now()
|
||||
WHERE id=$1 AND status='pending'`, id, note, actor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
// Distinguish missing vs already-reviewed.
|
||||
var st string
|
||||
if e := s.pool.QueryRow(ctx, "SELECT status FROM submission WHERE id=$1", id).Scan(&st); errors.Is(e, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return ErrConflict
|
||||
}
|
||||
_ = s.writeAudit(ctx, actor, "reject_submission", "submission", &id, []string{}, nil, map[string]string{"review_note": note})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApproveSubmission applies a pending contribution to the product store
|
||||
// (creating or supplementing a product), records community provenance + audit,
|
||||
// recomputes quality, and marks the submission approved.
|
||||
func (s *Store) ApproveSubmission(ctx context.Context, id, actor string) (*ProductDetail, error) {
|
||||
sub, err := s.GetSubmission(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sub.Status != "pending" {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
in := sub.Payload
|
||||
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
communityID, err := s.sourceIDTx(ctx, tx, "community")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve the target product (existing supplement vs new create).
|
||||
productID := ""
|
||||
if sub.TargetProductID != nil {
|
||||
productID = *sub.TargetProductID
|
||||
} else if in.GTIN != nil {
|
||||
var pid string
|
||||
if e := tx.QueryRow(ctx, "SELECT id FROM product WHERE gtin=$1", *in.GTIN).Scan(&pid); e == nil {
|
||||
productID = pid
|
||||
} else if !errors.Is(e, pgx.ErrNoRows) {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
|
||||
var brandID *string
|
||||
if in.BrandName != nil && strings.TrimSpace(*in.BrandName) != "" {
|
||||
bid, err := s.ensureBrand(ctx, tx, strings.TrimSpace(*in.BrandName))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
brandID = &bid
|
||||
}
|
||||
var gpc *string
|
||||
if in.CategoryID != nil && *in.CategoryID != "" {
|
||||
if err := tx.QueryRow(ctx, "SELECT gpc_brick_code FROM category WHERE id=$1", *in.CategoryID).Scan(&gpc); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
canonical, err := s.netCanonical(ctx, tx, in.NetContentValue, in.NetContentUnit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fields := submissionFields(in)
|
||||
|
||||
if productID == "" {
|
||||
// Create a new product from the contribution.
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO product (gtin, name, brand_id, category_id, gpc_brick_code,
|
||||
net_content_value, net_content_unit, net_content_canonical, country_of_origin, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'active') RETURNING id`,
|
||||
in.GTIN, in.Name, brandID, in.CategoryID, gpc,
|
||||
in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin).Scan(&productID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// Supplement an existing product: only overwrite fields the
|
||||
// contribution actually provides (COALESCE keeps current values).
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE product SET
|
||||
name=COALESCE(NULLIF($2,''), name),
|
||||
brand_id=COALESCE($3, brand_id),
|
||||
category_id=COALESCE($4, category_id),
|
||||
gpc_brick_code=COALESCE($5, gpc_brick_code),
|
||||
net_content_value=COALESCE($6, net_content_value),
|
||||
net_content_unit=COALESCE($7, net_content_unit),
|
||||
net_content_canonical=COALESCE($8, net_content_canonical),
|
||||
country_of_origin=COALESCE($9, country_of_origin),
|
||||
gtin=COALESCE($10, gtin)
|
||||
WHERE id=$1`,
|
||||
productID, in.Name, brandID, in.CategoryID, gpc,
|
||||
in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin, in.GTIN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// food_detail: upsert, preserving existing values where not provided.
|
||||
var nutriJSON []byte
|
||||
if len(in.Nutriments) > 0 {
|
||||
nutriJSON, _ = json.Marshal(in.Nutriments)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO food_detail (product_id, ingredients_text, nutriments, nutrition_basis, serving_size, nutri_score)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
ON CONFLICT (product_id) DO UPDATE SET
|
||||
ingredients_text=COALESCE(EXCLUDED.ingredients_text, food_detail.ingredients_text),
|
||||
nutriments=COALESCE(EXCLUDED.nutriments, food_detail.nutriments),
|
||||
nutrition_basis=COALESCE(EXCLUDED.nutrition_basis, food_detail.nutrition_basis),
|
||||
serving_size=COALESCE(EXCLUDED.serving_size, food_detail.serving_size),
|
||||
nutri_score=COALESCE(EXCLUDED.nutri_score, food_detail.nutri_score)`,
|
||||
productID, in.IngredientsText, nutriJSON, in.NutritionBasis, in.ServingSize, in.NutriScore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, im := range in.Images {
|
||||
url := strings.TrimSpace(im.URL)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
kind := im.Kind
|
||||
if kind != "front" && kind != "ingredients" && kind != "nutrition" {
|
||||
kind = "other"
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO product_image (product_id, url, kind, source_id) VALUES ($1,$2,$3,$4)`,
|
||||
productID, url, kind, communityID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range in.MSRP {
|
||||
if m.Amount <= 0 {
|
||||
continue
|
||||
}
|
||||
cur := m.Currency
|
||||
if cur == "" {
|
||||
cur = "CNY"
|
||||
}
|
||||
region := m.Region
|
||||
if region == "" {
|
||||
region = "CN"
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO product_msrp (product_id, amount, currency, region, source_id, source_url, effective_date, note)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
|
||||
productID, m.Amount, cur, region, communityID, m.SourceURL, m.EffectiveDate, m.Note); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.recomputeQualityTx(ctx, tx, productID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE submission SET status='approved', reviewed_by=$2, reviewed_at=now(), result_product_id=$3
|
||||
WHERE id=$1`, id, actor, productID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Field-level provenance for the contributed fields (community source).
|
||||
if len(fields) > 0 {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw)
|
||||
VALUES ($1,$2,NULL,$3,now(),NULL)`, productID, communityID, fields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = s.writeAudit(ctx, actor, "approve_submission", "product", &productID, fields,
|
||||
map[string]string{"submission_id": id}, map[string]string{"product_id": productID})
|
||||
|
||||
return s.GetProduct(ctx, productID)
|
||||
}
|
||||
|
||||
func (s *Store) sourceIDTx(ctx context.Context, tx pgx.Tx, name string) (string, error) {
|
||||
var id string
|
||||
err := tx.QueryRow(ctx, "SELECT id FROM source WHERE name=$1", name).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// submissionFields lists the product fields a contribution provides values for.
|
||||
func submissionFields(in SubmissionInput) []string {
|
||||
fields := []string{"name"}
|
||||
add := func(name string, present bool) {
|
||||
if present {
|
||||
fields = append(fields, name)
|
||||
}
|
||||
}
|
||||
add("gtin", in.GTIN != nil && *in.GTIN != "")
|
||||
add("brand", in.BrandName != nil && strings.TrimSpace(*in.BrandName) != "")
|
||||
add("category", in.CategoryID != nil && *in.CategoryID != "")
|
||||
add("net_content", in.NetContentValue != nil)
|
||||
add("country_of_origin", in.CountryOfOrigin != nil && *in.CountryOfOrigin != "")
|
||||
add("ingredients", in.IngredientsText != nil && *in.IngredientsText != "")
|
||||
add("nutriments", len(in.Nutriments) > 0)
|
||||
add("image", len(in.Images) > 0)
|
||||
return fields
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>OpenGoods</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">OpenGoods public site placeholder. Built assets are injected during Docker build.</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
// Package publicweb embeds the built public SPA (Vite dist). During Docker
|
||||
// builds the real dist/ is produced by the node stage and copied in before go
|
||||
// build; the committed placeholder keeps the package compilable for
|
||||
// `go build ./...`.
|
||||
package publicweb
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// Dist returns the embedded SPA filesystem rooted at dist/.
|
||||
func Dist() fs.FS {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
Reference in New Issue
Block a user