feat(web+api): 贡献表单按 archive_kind 动态生成字段
- categories 接口返回 archive_kind - 新增公开只读接口 /api/v1/kind-fields?kind=X 返回字段模板 - 投稿 payload 支持通用 attributes,审核通过写入 product.attributes (JSONB 合并) - food_detail 仅在含食品数据时才 upsert (药品/3C/通用不再产生空行) - 前端 Contribute 按所选品类 archive_kind 动态渲染字段 (食品营养 / 药品 18 字段 / 3C / 通用),除商品名外均选填 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -31,6 +31,7 @@ type SubmissionInput struct {
|
||||
CountryOfOrigin *string `json:"country_of_origin"`
|
||||
IngredientsText *string `json:"ingredients_text"`
|
||||
Nutriments map[string]any `json:"nutriments"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
NutritionBasis *string `json:"nutrition_basis"`
|
||||
ServingSize *string `json:"serving_size"`
|
||||
NutriScore *string `json:"nutri_score"`
|
||||
@@ -278,14 +279,19 @@ func (s *Store) ApproveSubmission(ctx context.Context, id, actor string) (*Produ
|
||||
|
||||
fields := submissionFields(in)
|
||||
|
||||
var attrJSON []byte
|
||||
if len(in.Attributes) > 0 {
|
||||
attrJSON, _ = json.Marshal(in.Attributes)
|
||||
}
|
||||
|
||||
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`,
|
||||
net_content_value, net_content_unit, net_content_canonical, country_of_origin, attributes, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,COALESCE($10::jsonb,'{}'::jsonb),'active') RETURNING id`,
|
||||
in.GTIN, in.Name, brandID, in.CategoryID, gpc,
|
||||
in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin).Scan(&productID)
|
||||
in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin, attrJSON).Scan(&productID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -302,21 +308,29 @@ UPDATE product SET
|
||||
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)
|
||||
gtin=COALESCE($10, gtin),
|
||||
attributes=product.attributes || COALESCE($11::jsonb,'{}'::jsonb)
|
||||
WHERE id=$1`,
|
||||
productID, in.Name, brandID, in.CategoryID, gpc,
|
||||
in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin, in.GTIN)
|
||||
in.NetContentValue, in.NetContentUnit, canonical, in.CountryOfOrigin, in.GTIN, attrJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// food_detail: upsert, preserving existing values where not provided.
|
||||
// food_detail: upsert only when the contribution carries food data, so a
|
||||
// non-food submission (drug/3C/generic) doesn't create an empty row.
|
||||
var nutriJSON []byte
|
||||
if len(in.Nutriments) > 0 {
|
||||
nutriJSON, _ = json.Marshal(in.Nutriments)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
foodPresent := len(in.Nutriments) > 0 ||
|
||||
(in.IngredientsText != nil && *in.IngredientsText != "") ||
|
||||
(in.NutritionBasis != nil && *in.NutritionBasis != "") ||
|
||||
(in.ServingSize != nil && *in.ServingSize != "") ||
|
||||
(in.NutriScore != nil && *in.NutriScore != "")
|
||||
if foodPresent {
|
||||
_, 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
|
||||
@@ -325,9 +339,10 @@ ON CONFLICT (product_id) DO UPDATE SET
|
||||
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
|
||||
productID, in.IngredientsText, nutriJSON, in.NutritionBasis, in.ServingSize, in.NutriScore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
for _, im := range in.Images {
|
||||
@@ -417,5 +432,14 @@ func submissionFields(in SubmissionInput) []string {
|
||||
add("ingredients", in.IngredientsText != nil && *in.IngredientsText != "")
|
||||
add("nutriments", len(in.Nutriments) > 0)
|
||||
add("image", len(in.Images) > 0)
|
||||
for k, v := range in.Attributes {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if s, ok := v.(string); ok && s == "" {
|
||||
continue
|
||||
}
|
||||
fields = append(fields, k)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ func (h *Handler) Router() http.Handler {
|
||||
})
|
||||
r.Get("/brands", h.ListBrands)
|
||||
r.Get("/categories", h.ListCategories)
|
||||
r.Get("/kind-fields", h.ListKindFields)
|
||||
r.Get("/sources/{id}", h.SourceByID)
|
||||
r.Get("/stats", h.Stats)
|
||||
})
|
||||
@@ -275,6 +276,21 @@ func (h *Handler) ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// ListKindFields returns the read-only spec field template for an archive kind,
|
||||
// used by the contribution form to render kind-specific inputs.
|
||||
func (h *Handler) ListKindFields(w http.ResponseWriter, r *http.Request) {
|
||||
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
|
||||
if kind == "" {
|
||||
writeError(w, r, http.StatusBadRequest, "bad_request", "missing kind")
|
||||
return
|
||||
}
|
||||
items, err := h.store.ListKindFields(r.Context(), kind)
|
||||
if h.handleErr(w, r, err) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "kind": kind})
|
||||
}
|
||||
|
||||
// SourceByID returns a single data source.
|
||||
func (h *Handler) SourceByID(w http.ResponseWriter, r *http.Request) {
|
||||
src, err := h.store.SourceByID(r.Context(), chi.URLParam(r, "id"))
|
||||
|
||||
@@ -501,12 +501,13 @@ type Category struct {
|
||||
Path string `json:"path"`
|
||||
GPCBrickCode *string `json:"gpc_brick_code"`
|
||||
Level int `json:"level"`
|
||||
ArchiveKind string `json:"archive_kind"`
|
||||
}
|
||||
|
||||
// ListCategories returns the full category tree ordered by path.
|
||||
func (s *Store) ListCategories(ctx context.Context) ([]Category, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
"SELECT id, name_zh, name_en, path::text, gpc_brick_code, level FROM category ORDER BY path")
|
||||
"SELECT id, name_zh, name_en, path::text, gpc_brick_code, level, COALESCE(archive_kind, 'generic') FROM category ORDER BY path")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -514,7 +515,7 @@ func (s *Store) ListCategories(ctx context.Context) ([]Category, error) {
|
||||
out := []Category{}
|
||||
for rows.Next() {
|
||||
var c Category
|
||||
if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.GPCBrickCode, &c.Level); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.NameZH, &c.NameEN, &c.Path, &c.GPCBrickCode, &c.Level, &c.ArchiveKind); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
@@ -522,6 +523,45 @@ func (s *Store) ListCategories(ctx context.Context) ([]Category, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// KindField describes one editable spec field for an archive kind. It drives
|
||||
// the dynamic contribution form (public, read-only view of the template).
|
||||
type KindField struct {
|
||||
Kind string `json:"kind"`
|
||||
FieldKey string `json:"field_key"`
|
||||
GroupLabel string `json:"group_label"`
|
||||
LabelZH string `json:"label_zh"`
|
||||
FieldType string `json:"field_type"`
|
||||
Unit *string `json:"unit"`
|
||||
Options []string `json:"options"`
|
||||
Placeholder *string `json:"placeholder"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Qualified bool `json:"qualified"`
|
||||
}
|
||||
|
||||
// ListKindFields returns the ordered field template for one archive kind so the
|
||||
// public contribution form can render kind-specific inputs.
|
||||
func (s *Store) ListKindFields(ctx context.Context, kind string) ([]KindField, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT kind, field_key, group_label, label_zh, field_type, unit, options,
|
||||
placeholder, sort_order, qualified
|
||||
FROM kind_field WHERE kind = $1 ORDER BY sort_order, field_key`, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []KindField{}
|
||||
for rows.Next() {
|
||||
var f KindField
|
||||
if err := rows.Scan(&f.Kind, &f.FieldKey, &f.GroupLabel, &f.LabelZH,
|
||||
&f.FieldType, &f.Unit, &f.Options, &f.Placeholder, &f.SortOrder,
|
||||
&f.Qualified); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// APIKey is the minimal metadata the public API needs to authorize a caller.
|
||||
type APIKey struct {
|
||||
ID string
|
||||
|
||||
Reference in New Issue
Block a user