Compare commits

...

9 Commits

Author SHA1 Message Date
rosemariejebbjtxbfp 85c58fbd52 feat(admin): add /api/import/bypos endpoint for bypos-collector import
CI / Go (api) (pull_request) Successful in 58s
CI / Python (ingestion) (pull_request) Successful in 16s
CI / Migrations (postgres) (pull_request) Successful in 27s
Brings the bypos JSONL import backend (handler + store) into main so the
bypos-collector tool's import feature works end-to-end. Records are upserted
on GTIN with manufacturer/MSRP/barcode/source provenance, quality recomputed
per product. Only status=hit records with a name are imported.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 13:30:27 +00:00
lixu 3aec0b84fe Merge pull request 'feat(web+api): 贡献表单按 archive_kind 动态生成字段' (#32) from devin/1782295354-contribution-form-dynamic into main
CI / Go (api) (push) Successful in 55s
CI / Python (ingestion) (push) Successful in 32s
CI / Migrations (postgres) (push) Successful in 26s
2026-06-24 18:07:05 +08:00
rosemariejebbjtxbfp bd1d3bde7c feat(web+api): 贡献表单按 archive_kind 动态生成字段
CI / Go (api) (pull_request) Successful in 1m3s
CI / Python (ingestion) (pull_request) Successful in 23s
CI / Migrations (postgres) (pull_request) Successful in 26s
- 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>
2026-06-24 10:02:46 +00:00
lixu 998471f6df Merge pull request 'feat: 新增药品(drug)商品档案格式' (#31) from devin/1782286467-drug-archive-kind into main
CI / Go (api) (push) Successful in 47s
CI / Python (ingestion) (push) Successful in 18s
CI / Migrations (postgres) (push) Successful in 27s
2026-06-24 15:39:07 +08:00
rosemariejebbjtxbfp 1f9b1ecd30 feat(migrations): add drug (药品) archive kind template + category subtree
CI / Go (api) (pull_request) Successful in 55s
CI / Python (ingestion) (pull_request) Successful in 23s
CI / Migrations (postgres) (pull_request) Successful in 27s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 07:36:43 +00:00
lixu 1d71617f71 Merge pull request 'chore(web): 添加微信域名校验文件' (#30) from devin/1782285002-wechat-domain-verify into main
CI / Go (api) (push) Successful in 51s
CI / Python (ingestion) (push) Successful in 30s
CI / Migrations (postgres) (push) Successful in 26s
2026-06-24 15:11:58 +08:00
rosemariejebbjtxbfp d18fc2470b chore(web): add WeChat domain verification file
CI / Go (api) (pull_request) Successful in 54s
CI / Python (ingestion) (pull_request) Successful in 26s
CI / Migrations (postgres) (pull_request) Successful in 27s
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 07:10:07 +00:00
lixu f29696607a Merge pull request 'feat(api): Redis read cache for product details and search (stage 0)' (#29) from devin/1782283345-redis-read-cache into main
CI / Go (api) (push) Successful in 50s
CI / Python (ingestion) (push) Successful in 37s
CI / Migrations (postgres) (push) Successful in 27s
2026-06-24 14:54:59 +08:00
rosemariejebbjtxbfp a75318b811 feat(api): Redis read cache for product details and search
CI / Go (api) (pull_request) Successful in 54s
CI / Python (ingestion) (pull_request) Successful in 19s
CI / Migrations (postgres) (pull_request) Successful in 27s
Stage-0 caching from docs/scalability.md. The Go API now caches hot
product-detail and search-result reads in Redis with a fail-open,
epoch-versioned scheme; Python ingestion bumps the epoch after a write
run to invalidate the cache globally in O(1).

- api/internal/cache: fail-open Cache (GetJSON/SetJSON) namespaced by an
  epoch counter (og:cache:epoch); disabled when Redis is unconfigured.
- store: ProductByID/ProductByGTIN (24h TTL) and SearchProducts (1h TTL)
  read-through the cache via WithCache.
- ingestion: bump_cache_epoch() called after update_off/seed_off/
  import_bypos/dedup commits when rows changed; best-effort, never fails
  a run. Adds redis dependency.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-24 06:52:56 +00:00
22 changed files with 982 additions and 54 deletions
+7 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/baicai2026-baicai/goods/api/internal/cache"
"github.com/baicai2026-baicai/goods/api/internal/config"
"github.com/baicai2026-baicai/goods/api/internal/handler"
"github.com/baicai2026-baicai/goods/api/internal/publicweb"
@@ -36,7 +37,12 @@ func main() {
if !limiter.Enabled() {
log.Print("warning: Redis not configured; public API rate limiting disabled")
}
h := handler.New(store.New(pool), publicweb.Dist()).
readCache := cache.New(cfg.RedisURL)
if !readCache.Enabled() {
log.Print("warning: Redis not configured; public API read cache disabled")
}
h := handler.New(store.New(pool).WithCache(readCache), publicweb.Dist()).
WithRateLimit(limiter, cfg.AnonRateLimitPerMin).
WithQuotas(cfg.AnonTotalQuota, cfg.RegisteredRateLimitPerMin, cfg.RegisteredQuotaTotal)
+2
View File
@@ -90,6 +90,8 @@ func (h *Handler) Router() http.Handler {
r.Put("/api/categories/{id}", h.UpdateCategory)
r.Delete("/api/categories/{id}", h.DeleteCategory)
r.Post("/api/import/bypos", h.ImportBypos)
r.Get("/api/submissions", h.ListSubmissions)
r.Get("/api/submissions/{id}", h.GetSubmission)
r.Post("/api/submissions/{id}/approve", h.ApproveSubmission)
+30
View File
@@ -0,0 +1,30 @@
package adminhandler
import (
"encoding/json"
"net/http"
"github.com/baicai2026-baicai/goods/api/internal/adminstore"
)
func (h *Handler) ImportBypos(w http.ResponseWriter, r *http.Request) {
var records []adminstore.ByposRecord
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 50<<20))
for dec.More() {
var rec adminstore.ByposRecord
if err := dec.Decode(&rec); err != nil {
continue
}
records = append(records, rec)
}
if len(records) == 0 {
writeError(w, http.StatusBadRequest, "empty", "\u6ca1\u6709\u53ef\u5bfc\u5165\u7684\u8bb0\u5f55")
return
}
result, err := h.store.ImportByposRecords(r.Context(), records)
if err != nil {
writeError(w, http.StatusInternalServerError, "import_error", err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
+217
View File
@@ -0,0 +1,217 @@
package adminstore
import (
"context"
"encoding/json"
"strings"
"github.com/jackc/pgx/v5"
)
type ByposRecord struct {
Barcode string `json:"barcode"`
Name string `json:"name"`
Spec string `json:"spec"`
Unit string `json:"unit"`
Area string `json:"area"`
Manufacturer string `json:"manufacturer"`
License string `json:"license"`
InPrice string `json:"in_price"`
SellPrice string `json:"sell_price"`
Status string `json:"status"`
RetMsg string `json:"retmsg"`
FetchedAt string `json:"fetched_at"`
Source string `json:"source"`
}
type ImportByposResult struct {
Loaded int `json:"loaded"`
Skipped int `json:"skipped"`
Errored int `json:"errored"`
}
const byposSourceName = "bypos\u4e2d\u5fc3\u5e93"
const byposSourceURL = "https://zc.bypos.net"
func (s *Store) ensureByposSource(ctx context.Context, tx pgx.Tx) (string, error) {
var id string
err := tx.QueryRow(ctx, `
INSERT INTO source (name, homepage, license, trust_weight)
VALUES ($1, $2, 'proprietary', 0.6)
ON CONFLICT (name) DO UPDATE SET homepage = EXCLUDED.homepage
RETURNING id`, byposSourceName, byposSourceURL).Scan(&id)
return id, err
}
func (s *Store) ensureManufacturer(ctx context.Context, tx pgx.Tx, name string, country *string) (string, error) {
norm := strings.Join(strings.Fields(strings.ToLower(name)), " ")
var id string
err := tx.QueryRow(ctx, `
INSERT INTO manufacturer (name, normalized_name, country)
VALUES ($1, $2, $3)
ON CONFLICT (normalized_name) DO UPDATE SET name = manufacturer.name
RETURNING id`, name, norm, country).Scan(&id)
return id, err
}
func (s *Store) importByposRecord(ctx context.Context, tx pgx.Tx, rec ByposRecord, sourceID string) error {
if rec.Status != "hit" || strings.TrimSpace(rec.Name) == "" {
return nil
}
barcode := strings.TrimSpace(rec.Barcode)
name := strings.TrimSpace(rec.Name)
var country *string
if strings.HasPrefix(barcode, "69") {
c := "\u4e2d\u56fd"
country = &c
}
var manufacturerID *string
if mfr := strings.TrimSpace(rec.Manufacturer); mfr != "" {
mid, err := s.ensureManufacturer(ctx, tx, mfr, country)
if err != nil {
return err
}
manufacturerID = &mid
}
attrs := map[string]interface{}{}
if v := strings.TrimSpace(rec.Spec); v != "" {
attrs["spec"] = v
}
if v := strings.TrimSpace(rec.Unit); v != "" {
attrs["pack_unit"] = v
}
if v := strings.TrimSpace(rec.Area); v != "" {
attrs["origin_area"] = v
}
if v := strings.TrimSpace(rec.License); v != "" {
attrs["production_license"] = v
}
attrsJSON, _ := json.Marshal(attrs)
var productID string
if barcode != "" {
err := tx.QueryRow(ctx, `
INSERT INTO product (gtin, name, manufacturer_id, country_of_origin, attributes, status)
VALUES ($1, $2, $3, $4, $5, 'active')
ON CONFLICT (gtin) WHERE gtin IS NOT NULL DO UPDATE SET
name = EXCLUDED.name,
manufacturer_id = COALESCE(EXCLUDED.manufacturer_id, product.manufacturer_id),
country_of_origin = COALESCE(EXCLUDED.country_of_origin, product.country_of_origin),
attributes = product.attributes || EXCLUDED.attributes
RETURNING id`, barcode, name, manufacturerID, country, attrsJSON).Scan(&productID)
if err != nil {
return err
}
} else {
err := tx.QueryRow(ctx, `
INSERT INTO product (name, manufacturer_id, country_of_origin, attributes, status)
VALUES ($1, $2, $3, $4, 'active')
RETURNING id`, name, manufacturerID, country, attrsJSON).Scan(&productID)
if err != nil {
return err
}
}
_, _ = tx.Exec(ctx, "DELETE FROM product_msrp WHERE product_id = $1 AND source_id = $2", productID, sourceID)
if sp := strings.TrimSpace(rec.SellPrice); sp != "" && sp != "0" {
_, _ = tx.Exec(ctx, `
INSERT INTO product_msrp (product_id, amount, currency, region, source_id, source_url)
VALUES ($1, $2::numeric, 'CNY', 'CN', $3, $4)`, productID, sp, sourceID, byposSourceURL)
}
_, _ = tx.Exec(ctx, "DELETE FROM product_source WHERE product_id = $1 AND source_id = $2", productID, sourceID)
fields := []string{"name", "country_of_origin"}
if manufacturerID != nil {
fields = append(fields, "manufacturer")
}
if len(attrs) > 0 {
fields = append(fields, "attributes")
}
if barcode != "" {
fields = append(fields, "gtin")
}
rawJSON, _ := json.Marshal(rec)
fetchedAt := strings.TrimSpace(rec.FetchedAt)
if fetchedAt == "" {
fetchedAt = ""
}
if fetchedAt != "" {
_, err := tx.Exec(ctx, `
INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw)
VALUES ($1, $2, $3, $4, $5::timestamptz, $6)`,
productID, sourceID, byposSourceURL, fields, fetchedAt, rawJSON)
if err != nil {
return err
}
} else {
_, err := tx.Exec(ctx, `
INSERT INTO product_source (product_id, source_id, url, fields, fetched_at, raw)
VALUES ($1, $2, $3, $4, now(), $5)`,
productID, sourceID, byposSourceURL, fields, rawJSON)
if err != nil {
return err
}
}
if barcode != "" {
gtinType := "EAN13"
switch len(barcode) {
case 8:
gtinType = "EAN8"
case 12:
gtinType = "UPC"
case 14:
gtinType = "GTIN14"
}
_, _ = tx.Exec(ctx, `
INSERT INTO product_barcode (product_id, gtin, gtin_type, pack_level, is_primary, source_id)
VALUES ($1, $2, $3, 'each', true, $4)
ON CONFLICT (gtin) DO NOTHING`, productID, barcode, gtinType, sourceID)
}
_, err := s.recomputeQualityTx(ctx, tx, productID)
return err
}
func (s *Store) ImportByposRecords(ctx context.Context, records []ByposRecord) (*ImportByposResult, error) {
result := &ImportByposResult{}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
sourceID, err := s.ensureByposSource(ctx, tx)
if err != nil {
return nil, err
}
for _, rec := range records {
if rec.Status != "hit" || strings.TrimSpace(rec.Name) == "" {
result.Skipped++
continue
}
sp, spErr := tx.Begin(ctx)
if spErr != nil {
result.Errored++
continue
}
if err := s.importByposRecord(ctx, sp, rec, sourceID); err != nil {
sp.Rollback(ctx)
result.Errored++
} else {
sp.Commit(ctx)
result.Loaded++
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
+34 -10
View File
@@ -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
}
+126
View File
@@ -0,0 +1,126 @@
// Package cache is a Redis-backed, fail-open read cache for the public API.
//
// It caches hot product details and search results so repeated reads avoid
// PostgreSQL. Like the ratelimit package, every operation fails open: if Redis
// is unavailable or misconfigured the caller simply falls back to the database,
// so the cache can never take the API down or serve stale data after Redis loss.
//
// Invalidation is global and O(1): keys are namespaced by an epoch counter
// stored in Redis (og:cache:epoch). The Python ingestion bumps that counter
// after a write run, which logically invalidates every cached entry at once
// while old keys age out via their TTL. The epoch is read at most once per
// refresh interval per process, so it adds no per-request round trip.
package cache
import (
"context"
"encoding/json"
"errors"
"log"
"strconv"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// epochKey is the Redis key holding the global cache generation counter.
const epochKey = "og:cache:epoch"
// epochRefresh bounds how often a process re-reads the epoch from Redis.
const epochRefresh = 10 * time.Second
// opTimeout caps any single Redis operation so a slow backend never blocks a
// request beyond this; on timeout the cache fails open.
const opTimeout = 150 * time.Millisecond
// Cache wraps a Redis client. A nil-backed Cache (Redis unconfigured) disables
// caching: every Get misses and every Set is a no-op.
type Cache struct {
rdb *redis.Client
mu sync.RWMutex
epoch int64
epochSetAt time.Time
epochOK bool
}
// New builds a Cache from a redis:// URL. On a parse error it logs and returns a
// disabled (fail-open) cache so the server still boots.
func New(redisURL string) *Cache {
opt, err := redis.ParseURL(redisURL)
if err != nil {
log.Printf("cache: invalid redis url %q: %v (caching disabled)", redisURL, err)
return &Cache{}
}
return &Cache{rdb: redis.NewClient(opt)}
}
// Enabled reports whether a Redis backend is configured.
func (c *Cache) Enabled() bool { return c != nil && c.rdb != nil }
// epochNow returns the current cache generation, reading it from Redis at most
// once per epochRefresh. On any Redis error it keeps the last known value and
// throttles re-reads so a down backend cannot slow the hot path.
func (c *Cache) epochNow(ctx context.Context) int64 {
c.mu.RLock()
if c.epochOK && time.Since(c.epochSetAt) < epochRefresh {
e := c.epoch
c.mu.RUnlock()
return e
}
c.mu.RUnlock()
cctx, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
n, err := c.rdb.Get(cctx, epochKey).Int64()
c.mu.Lock()
defer c.mu.Unlock()
switch {
case err == nil:
c.epoch = n
case errors.Is(err, redis.Nil):
c.epoch = 0
}
c.epochSetAt = time.Now()
c.epochOK = true
return c.epoch
}
// key namespaces a logical suffix under the current epoch.
func (c *Cache) key(ctx context.Context, suffix string) string {
return "og:v" + strconv.FormatInt(c.epochNow(ctx), 10) + ":" + suffix
}
// GetJSON unmarshals the cached value for suffix into dest and reports a hit.
// Any miss, decode error, or Redis error returns false (fail-open).
func (c *Cache) GetJSON(ctx context.Context, suffix string, dest any) bool {
if !c.Enabled() {
return false
}
k := c.key(ctx, suffix)
cctx, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
b, err := c.rdb.Get(cctx, k).Bytes()
if err != nil {
return false
}
return json.Unmarshal(b, dest) == nil
}
// SetJSON stores val (JSON-encoded) for suffix with the given TTL. Best effort:
// marshal or Redis errors are ignored.
func (c *Cache) SetJSON(ctx context.Context, suffix string, val any, ttl time.Duration) {
if !c.Enabled() {
return
}
b, err := json.Marshal(val)
if err != nil {
return
}
k := c.key(ctx, suffix)
cctx, cancel := context.WithTimeout(ctx, opTimeout)
defer cancel()
_ = c.rdb.Set(cctx, k, b, ttl).Err()
}
+84
View File
@@ -0,0 +1,84 @@
package cache
import (
"context"
"fmt"
"os"
"testing"
"time"
)
// TestDisabledFailsOpen verifies a Cache without a Redis backend never panics,
// always misses, and silently drops writes.
func TestDisabledFailsOpen(t *testing.T) {
c := New("not-a-valid-url") // parse error => disabled
if c.Enabled() {
t.Fatal("expected cache to be disabled for invalid url")
}
c.SetJSON(context.Background(), "k", map[string]int{"a": 1}, time.Minute)
var dst map[string]int
if c.GetJSON(context.Background(), "k", &dst) {
t.Fatalf("disabled cache must always miss, got %+v", dst)
}
}
func testCache(t *testing.T) *Cache {
t.Helper()
url := os.Getenv("OPENGOODS_REDIS_URL")
if url == "" {
url = "redis://localhost:6379/0"
}
c := New(url)
if !c.Enabled() {
t.Skip("redis not configured")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := c.rdb.Ping(ctx).Err(); err != nil {
t.Skipf("redis not reachable: %v", err)
}
return c
}
// TestRoundTrip stores then reads a value back.
func TestRoundTrip(t *testing.T) {
c := testCache(t)
ctx := context.Background()
suffix := fmt.Sprintf("test:rt:%d", time.Now().UnixNano())
c.SetJSON(ctx, suffix, map[string]any{"name": "foo", "n": float64(3)}, time.Minute)
got := map[string]any{}
if !c.GetJSON(ctx, suffix, &got) {
t.Fatal("expected cache hit after set")
}
if got["name"] != "foo" || got["n"] != float64(3) {
t.Fatalf("unexpected payload: %+v", got)
}
}
// TestEpochInvalidation verifies that bumping the epoch counter logically drops
// every previously cached entry.
func TestEpochInvalidation(t *testing.T) {
c := testCache(t)
ctx := context.Background()
suffix := fmt.Sprintf("test:epoch:%d", time.Now().UnixNano())
c.SetJSON(ctx, suffix, map[string]int{"v": 1}, time.Minute)
var dst map[string]int
if !c.GetJSON(ctx, suffix, &dst) {
t.Fatal("expected hit before epoch bump")
}
// Simulate an ingestion write bumping the global epoch.
if err := c.rdb.Incr(ctx, epochKey).Err(); err != nil {
t.Fatalf("incr epoch: %v", err)
}
// Force the process to re-read the epoch rather than use its cached value.
c.mu.Lock()
c.epochOK = false
c.mu.Unlock()
if c.GetJSON(ctx, suffix, &dst) {
t.Fatal("entry should be invisible after epoch bump")
}
}
+16
View File
@@ -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"))
+119 -5
View File
@@ -4,21 +4,35 @@ package store
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/baicai2026-baicai/goods/api/internal/cache"
)
// Cache TTLs for the public read cache. Product details are far less volatile
// than search result sets, so they live longer; both are also invalidated
// wholesale whenever ingestion bumps the cache epoch.
const (
productCacheTTL = 24 * time.Hour
searchCacheTTL = time.Hour
)
// ErrNotFound is returned when a requested row does not exist.
var ErrNotFound = errors.New("not found")
// Store wraps a PostgreSQL connection pool.
// Store wraps a PostgreSQL connection pool and an optional read cache.
type Store struct {
pool *pgxpool.Pool
pool *pgxpool.Pool
cache *cache.Cache
}
// New constructs a Store from an existing pgx pool.
@@ -26,6 +40,30 @@ func New(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// WithCache attaches a Redis-backed read cache. A nil or disabled cache leaves
// the Store reading straight from PostgreSQL.
func (s *Store) WithCache(c *cache.Cache) *Store {
s.cache = c
return s
}
// cacheGet reads a cached JSON value into dest, reporting a hit. It is a no-op
// miss when no cache is attached.
func (s *Store) cacheGet(ctx context.Context, suffix string, dest any) bool {
if s.cache == nil {
return false
}
return s.cache.GetJSON(ctx, suffix, dest)
}
// cacheSet stores a JSON value when a cache is attached.
func (s *Store) cacheSet(ctx context.Context, suffix string, val any, ttl time.Duration) {
if s.cache == nil {
return
}
s.cache.SetJSON(ctx, suffix, val, ttl)
}
// Ping verifies database connectivity.
func (s *Store) Ping(ctx context.Context) error {
return s.pool.Ping(ctx)
@@ -222,6 +260,10 @@ func stringifyAttr(v any) string {
// ProductByGTIN looks up an active product by any of its barcodes.
func (s *Store) ProductByGTIN(ctx context.Context, gtin string) (*Product, error) {
const suffix = "prod:gtin:"
if cached := new(Product); s.cacheGet(ctx, suffix+gtin, cached) {
return cached, nil
}
row := s.pool.QueryRow(ctx, productSelect+`
WHERE p.status = 'active'
AND (p.gtin = $1 OR EXISTS (
@@ -238,11 +280,16 @@ func (s *Store) ProductByGTIN(ctx context.Context, gtin string) (*Product, error
if p.Specs, err = s.buildSpecs(ctx, p.ArchiveKind, attrs); err != nil {
return nil, err
}
s.cacheSet(ctx, suffix+gtin, p, productCacheTTL)
return p, nil
}
// ProductByID looks up a product by its UUID.
func (s *Store) ProductByID(ctx context.Context, id string) (*Product, error) {
const suffix = "prod:id:"
if cached := new(Product); s.cacheGet(ctx, suffix+id, cached) {
return cached, nil
}
row := s.pool.QueryRow(ctx, productSelect+" WHERE p.id = $1", id)
p, attrs, err := scanProduct(row)
if err != nil {
@@ -254,6 +301,7 @@ func (s *Store) ProductByID(ctx context.Context, id string) (*Product, error) {
if p.Specs, err = s.buildSpecs(ctx, p.ArchiveKind, attrs); err != nil {
return nil, err
}
s.cacheSet(ctx, suffix+id, p, productCacheTTL)
return p, nil
}
@@ -268,6 +316,11 @@ const fuzzyThreshold = "0.42"
// similarity blended with quality_score so the best, most-complete records
// surface first. Without a query, results are ordered by quality_score.
func (s *Store) SearchProducts(ctx context.Context, f SearchFilters, limit, offset int) ([]ProductSummary, int, error) {
suffix := searchCacheSuffix(f, limit, offset)
if entry := new(searchCacheEntry); s.cacheGet(ctx, suffix, entry) {
return entry.Items, entry.Total, nil
}
args := []any{}
where := "WHERE p.status = 'active'"
@@ -334,7 +387,28 @@ LEFT JOIN category c ON c.id = p.category_id `
}
out = append(out, ps)
}
return out, total, rows.Err()
if err := rows.Err(); err != nil {
return nil, 0, err
}
s.cacheSet(ctx, suffix, searchCacheEntry{Items: out, Total: total}, searchCacheTTL)
return out, total, nil
}
// searchCacheEntry is the cached payload for a SearchProducts call.
type searchCacheEntry struct {
Items []ProductSummary `json:"items"`
Total int `json:"total"`
}
// searchCacheSuffix derives a stable cache key from the full filter set and
// paging window so distinct queries never collide.
func searchCacheSuffix(f SearchFilters, limit, offset int) string {
raw := strings.Join([]string{
f.Query, f.Category, f.Brand, f.Country,
strconv.Itoa(limit), strconv.Itoa(offset),
}, "\x1f")
sum := sha1.Sum([]byte(raw))
return "search:" + hex.EncodeToString(sum[:])
}
// Nutriments returns just the nutrition payload for a product.
@@ -427,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
}
@@ -440,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)
@@ -448,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
+52
View File
@@ -0,0 +1,52 @@
"""Read-cache invalidation signal for the public API.
The Go API caches hot product details and search results in Redis, namespacing
every key by a global generation counter (``og:cache:epoch``). Bumping that
counter logically invalidates the entire cache in one O(1) operation while the
old keys age out via their TTL.
Ingestion is the only writer to the database, so after a run that changed data
it calls :func:`bump_cache_epoch` to make those changes visible immediately
instead of waiting for per-key TTLs to expire.
Like the API's cache, this is strictly best effort and fails open: if Redis is
unconfigured or unreachable the ingestion run still succeeds, and stale entries
simply expire on their own.
"""
from __future__ import annotations
import logging
import os
logger = logging.getLogger(__name__)
EPOCH_KEY = "og:cache:epoch"
def default_redis_url() -> str | None:
"""Return the configured Redis URL, or ``None`` when caching is disabled."""
return os.environ.get("OPENGOODS_REDIS_URL") or None
def bump_cache_epoch(url: str | None = None) -> bool:
"""Increment the API cache generation counter.
Returns ``True`` if the counter was bumped, ``False`` if caching is disabled
or Redis was unreachable. Never raises: invalidation failures must not fail
an ingestion run.
"""
url = url or default_redis_url()
if not url:
return False
try:
import redis # imported lazily so the dependency is optional at runtime
client = redis.Redis.from_url(url, socket_timeout=2, socket_connect_timeout=2)
new_epoch = client.incr(EPOCH_KEY)
client.close()
logger.info("bumped API cache epoch to %s", new_epoch)
return True
except Exception as exc: # noqa: BLE001 - invalidation is best effort
logger.warning("cache epoch bump skipped: %s", exc)
return False
+3
View File
@@ -12,6 +12,7 @@ import sys
import psycopg
from opengoods.cache import bump_cache_epoch
from opengoods.etl.dedup import dedup_all
from opengoods.etl.load import default_dsn
@@ -23,6 +24,8 @@ def run(args: argparse.Namespace) -> int:
conn.rollback()
else:
conn.commit()
if not args.dry_run and summary["merged"]:
bump_cache_epoch()
mode = "dry-run" if args.dry_run else "applied"
print(f"{mode} groups={summary['groups']} merged={summary['merged']}")
return 0
+3
View File
@@ -25,6 +25,7 @@ from collections.abc import Iterator
import psycopg
from opengoods.adapters.bypos import transform_bypos
from opengoods.cache import bump_cache_epoch
from opengoods.etl.load import default_dsn, ensure_bypos_source, load_bypos_record_safe
@@ -59,6 +60,8 @@ def run(args: argparse.Namespace) -> int:
else:
errored += 1
conn.commit()
if loaded:
bump_cache_epoch()
print(f"loaded={loaded} skipped={skipped} errored={errored}")
return 0
+3
View File
@@ -28,6 +28,7 @@ from opengoods.adapters.openfoodfacts import (
is_cn_gs1,
read_dump,
)
from opengoods.cache import bump_cache_epoch
from opengoods.etl.load import default_dsn, ensure_source, load_record_safe
from opengoods.etl.transform import transform
@@ -67,6 +68,8 @@ def run(args: argparse.Namespace) -> int:
else:
errored += 1
conn.commit()
if loaded:
bump_cache_epoch()
print(f"loaded={loaded} skipped={skipped} errored={errored}")
return 0
+3
View File
@@ -17,6 +17,7 @@ import sys
import psycopg
from opengoods.adapters.openfoodfacts import SOURCE_NAME, OpenFoodFactsAdapter
from opengoods.cache import bump_cache_epoch
from opengoods.etl.load import default_dsn, ensure_source, load_record_safe
from opengoods.etl.state import get_watermark, set_watermark
from opengoods.etl.transform import transform
@@ -49,6 +50,8 @@ def run(args: argparse.Namespace) -> int:
stats={"loaded": loaded, "skipped": skipped, "errored": errored, "since": since},
)
conn.commit()
if loaded:
bump_cache_epoch()
print(
f"since={since} loaded={loaded} skipped={skipped} "
f"errored={errored} watermark={high_watermark}"
+1
View File
@@ -6,6 +6,7 @@ requires-python = ">=3.11"
dependencies = [
"httpx>=0.27",
"psycopg[binary]>=3.2",
"redis>=5.0,<6",
]
[project.optional-dependencies]
+44
View File
@@ -0,0 +1,44 @@
"""Tests for the best-effort API cache invalidation signal."""
from __future__ import annotations
import sys
import opengoods.cache as cache
def test_disabled_when_no_url(monkeypatch):
monkeypatch.delenv("OPENGOODS_REDIS_URL", raising=False)
assert cache.default_redis_url() is None
assert cache.bump_cache_epoch() is False
def test_bump_fails_open_when_unreachable(monkeypatch):
# An unroutable URL must not raise; the run still succeeds.
monkeypatch.setenv("OPENGOODS_REDIS_URL", "redis://127.0.0.1:1/0")
assert cache.bump_cache_epoch() is False
def test_bump_increments_epoch(monkeypatch):
calls = {}
class FakeClient:
def incr(self, key):
calls["key"] = key
return 7
def close(self):
calls["closed"] = True
class FakeRedis:
@staticmethod
def from_url(url, **kwargs):
calls["url"] = url
return FakeClient()
monkeypatch.setitem(sys.modules, "redis", type("M", (), {"Redis": FakeRedis}))
assert cache.bump_cache_epoch("redis://example:6379/0") is True
assert calls["key"] == "og:cache:epoch"
assert calls["url"] == "redis://example:6379/0"
assert calls["closed"] is True
@@ -0,0 +1,8 @@
-- Reverse 0013: detach products from the seeded drug categories, drop the drug
-- category subtree, and remove the drug field template.
UPDATE product SET category_id = NULL
WHERE category_id IN (SELECT id FROM category WHERE path <@ 'drug');
DELETE FROM category WHERE path <@ 'drug';
DELETE FROM kind_field WHERE kind = 'drug';
+43
View File
@@ -0,0 +1,43 @@
-- Drug (药品) archive kind: a new domain on the generic archive_kind framework
-- (see 0010). No new table — drug spec values live in product.attributes JSONB,
-- driven by the kind_field template below, plus a 'drug' category subtree.
-- Field template for the drug domain. field_type is constrained to
-- text/number/textarea/select/list by kind_field_type_chk. The `qualified`
-- fields are the identifying / regulatory essentials that count toward the
-- kind-aware completeness score.
INSERT INTO kind_field (kind, field_key, group_label, label_zh, field_type, unit, options, sort_order, qualified) VALUES
('drug','approval_number', '基础信息','批准文号', 'text', NULL, '{}', 10, true),
('drug','generic_name', '基础信息','通用名', 'text', NULL, '{}', 20, true),
('drug','trade_name', '基础信息','商品名', 'text', NULL, '{}', 30, false),
('drug','dosage_form', '基础信息','剂型', 'select', NULL, '{片剂,胶囊剂,颗粒剂,散剂,丸剂,注射剂,口服液,糖浆剂,软膏剂,乳膏剂,凝胶剂,滴剂,喷雾剂,气雾剂,栓剂,贴剂,其他}', 40, true),
('drug','specification', '基础信息','规格', 'text', NULL, '{}', 50, true),
('drug','pack_spec', '基础信息','包装规格', 'text', NULL, '{}', 60, false),
('drug','rx_class', '分类与企业','处方分类', 'select', NULL, '{处方药,甲类OTC,乙类OTC}', 70, true),
('drug','manufacturer', '分类与企业','生产企业', 'text', NULL, '{}', 80, true),
('drug','mah', '分类与企业','上市许可持有人','text', NULL, '{}', 90, false),
('drug','indications', '说明书','适应症/功能主治', 'textarea', NULL, '{}', 100, true),
('drug','dosage_usage', '说明书','用法用量', 'textarea', NULL, '{}', 110, false),
('drug','adverse_reactions', '说明书','不良反应', 'textarea', NULL, '{}', 120, false),
('drug','contraindications', '说明书','禁忌', 'textarea', NULL, '{}', 130, false),
('drug','precautions', '说明书','注意事项', 'textarea', NULL, '{}', 140, false),
('drug','interactions', '说明书','药物相互作用', 'textarea', NULL, '{}', 150, false),
('drug','storage', '贮藏与有效期','贮藏', 'text', NULL, '{}', 160, false),
('drug','shelf_life', '贮藏与有效期','有效期', 'text', NULL, '{}', 170, false),
('drug','spec_source', '贮藏与有效期','说明书来源', 'text', NULL, '{}', 180, false);
-- Drug category tree. ltree labels are English slugs (no spaces/Chinese);
-- Chinese names live in category.name_zh.
INSERT INTO category (name_zh, name_en, parent_id, path, level, archive_kind)
VALUES ('药品', 'Drug', NULL, 'drug', 0, 'drug');
INSERT INTO category (name_zh, name_en, parent_id, path, level, archive_kind)
SELECT v.name_zh, v.name_en, c.id, v.path::ltree, 1, 'drug'
FROM (VALUES
('化学药品', 'Chemical drug', 'drug.chemical'),
('中成药', 'Chinese patent medicine', 'drug.tcm_patent'),
('中药饮片', 'Chinese herbal pieces', 'drug.tcm_herb'),
('生物制品', 'Biological product', 'drug.biological'),
('疫苗', 'Vaccine', 'drug.vaccine')
) AS v(name_zh, name_en, path)
JOIN category c ON c.path = 'drug';
@@ -0,0 +1 @@
9ef4c7d0ea804c11bc637f4e82198e98af908894
+5 -1
View File
@@ -1,4 +1,4 @@
import type { Category, Product, ProductSummary, SubmissionInput } from "./types";
import type { Category, KindField, Product, ProductSummary, SubmissionInput } from "./types";
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
@@ -67,6 +67,10 @@ export const api = {
},
product: (id: string) => req<Product>(`/api/v1/products/${id}`),
categories: () => req<{ items: Category[] }>(`/api/v1/categories`),
kindFields: (kind: string) =>
req<{ items: KindField[]; kind: string }>(
`/api/v1/kind-fields?kind=${encodeURIComponent(kind)}`,
),
submit: (input: SubmissionInput) =>
req<{ id: string; status: string }>(`/api/public/submissions`, {
method: "POST",
+166 -37
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { CheckCircle2, PlusCircle, Trash2 } from "lucide-react";
import { api } from "../api";
import type { Category, SubmissionImage, SubmissionInput } from "../types";
import type { Category, KindField, SubmissionImage, SubmissionInput } from "../types";
const NUTRI_FIELDS: { key: string; label: string }[] = [
{ key: "energy_kcal", label: "能量 (kcal)" },
@@ -35,6 +35,8 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
const [ingredients, setIngredients] = useState("");
const [basis, setBasis] = useState("");
const [nutri, setNutri] = useState<Record<string, string>>({});
const [kindFields, setKindFields] = useState<KindField[]>([]);
const [attrs, setAttrs] = useState<Record<string, string>>({});
const [images, setImages] = useState<SubmissionImage[]>([]);
const [imageURL, setImageURL] = useState("");
const [submitter, setSubmitter] = useState("");
@@ -45,6 +47,33 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
api.categories().then((r) => setCategories(r.items)).catch(() => undefined);
}, []);
// Derive the archive kind from the selected category. No category => generic.
const kind = useMemo(() => {
const c = categories.find((x) => x.id === categoryID);
return c?.archive_kind || "generic";
}, [categories, categoryID]);
// Fetch the kind-specific field template for non-food kinds.
useEffect(() => {
setAttrs({});
if (kind === "food" || kind === "generic") {
setKindFields([]);
return;
}
let alive = true;
api
.kindFields(kind)
.then((r) => {
if (alive) setKindFields(r.items);
})
.catch(() => {
if (alive) setKindFields([]);
});
return () => {
alive = false;
};
}, [kind]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (name.trim() === "") {
@@ -55,9 +84,31 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
setError("");
const nutriments: Record<string, number> = {};
for (const [k, v] of Object.entries(nutri)) {
const n = parseFloat(v);
if (!Number.isNaN(n)) nutriments[k] = n;
if (kind === "food") {
for (const [k, v] of Object.entries(nutri)) {
const n = parseFloat(v);
if (!Number.isNaN(n)) nutriments[k] = n;
}
}
const attributes: Record<string, unknown> = {};
if (kind !== "food" && kind !== "generic") {
for (const f of kindFields) {
const raw = (attrs[f.field_key] || "").trim();
if (raw === "") continue;
if (f.field_type === "number") {
const n = parseFloat(raw);
if (!Number.isNaN(n)) attributes[f.field_key] = n;
} else if (f.field_type === "list") {
const parts = raw
.split(/[\n,,、]/)
.map((s) => s.trim())
.filter((s) => s !== "");
if (parts.length) attributes[f.field_key] = parts;
} else {
attributes[f.field_key] = raw;
}
}
}
const input: SubmissionInput = {
@@ -68,9 +119,10 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
net_content_value: field(netValue) ? parseFloat(netValue) : null,
net_content_unit: field(netUnit),
country_of_origin: field(country),
ingredients_text: field(ingredients),
ingredients_text: kind === "food" ? field(ingredients) : null,
nutriments: Object.keys(nutriments).length ? nutriments : null,
nutrition_basis: basis || null,
attributes: Object.keys(attributes).length ? attributes : null,
nutrition_basis: kind === "food" && basis ? basis : null,
images: images.length ? images : undefined,
submitter_name: field(submitter),
submitter_contact: field(contact),
@@ -107,11 +159,66 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
const input = "input";
const label = "block text-xs font-medium text-gray-500 mb-1";
// Group the kind fields by their group label, preserving template order.
const groups: { label: string; fields: KindField[] }[] = [];
for (const f of kindFields) {
let g = groups.find((x) => x.label === f.group_label);
if (!g) {
g = { label: f.group_label, fields: [] };
groups.push(g);
}
g.fields.push(f);
}
const KIND_LABEL: Record<string, string> = {
drug: "药品",
electronics: "数码 3C",
food: "食品",
generic: "通用",
};
function renderField(f: KindField) {
const val = attrs[f.field_key] || "";
const set = (v: string) => setAttrs((p) => ({ ...p, [f.field_key]: v }));
const lbl = f.unit ? `${f.label_zh} (${f.unit})` : f.label_zh;
return (
<div key={f.field_key} className={f.field_type === "textarea" || f.field_type === "list" ? "sm:col-span-2" : ""}>
<label className={label}>
{lbl}
{f.qualified && <span className="text-brand-500"> *</span>}
</label>
{f.field_type === "select" ? (
<select className={input} value={val} onChange={(e) => set(e.target.value)}>
<option value=""></option>
{(f.options || []).map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
) : f.field_type === "textarea" ? (
<textarea className={input} rows={3} value={val} placeholder={f.placeholder || ""} onChange={(e) => set(e.target.value)} />
) : f.field_type === "list" ? (
<textarea className={input} rows={2} value={val} placeholder={f.placeholder || "每行一项,或用、逗号分隔"} onChange={(e) => set(e.target.value)} />
) : (
<input
type={f.field_type === "number" ? "number" : "text"}
step={f.field_type === "number" ? "any" : undefined}
className={input}
value={val}
placeholder={f.placeholder || ""}
onChange={(e) => set(e.target.value)}
/>
)}
</div>
);
}
return (
<form onSubmit={submit} className="max-w-3xl mx-auto animate-fade-up">
<h1 className="text-2xl font-semibold tracking-tight text-gray-900"></h1>
<p className="mt-1 text-sm text-gray-500">
<b></b> *
<b></b>
</p>
{error && (
@@ -143,6 +250,11 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
</option>
))}
</select>
{categoryID && (
<p className="mt-1 text-xs text-gray-400">
{KIND_LABEL[kind] || kind}
</p>
)}
</div>
<div>
<label className={label}></label>
@@ -165,39 +277,56 @@ export default function Contribute({ onDone }: { onDone: () => void }) {
</div>
</div>
<div className="card p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-3"></h2>
<label className={label}></label>
<textarea
className={input}
rows={3}
value={ingredients}
onChange={(e) => setIngredients(e.target.value)}
/>
<div className="mt-3">
<label className={label}></label>
<select className={input} value={basis} onChange={(e) => setBasis(e.target.value)}>
<option value=""></option>
<option value="per_100g"> 100g</option>
<option value="per_100ml"> 100ml</option>
<option value="per_serving"></option>
</select>
{kind === "food" && (
<div className="card p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-3"></h2>
<label className={label}></label>
<textarea
className={input}
rows={3}
value={ingredients}
onChange={(e) => setIngredients(e.target.value)}
/>
<div className="mt-3">
<label className={label}></label>
<select className={input} value={basis} onChange={(e) => setBasis(e.target.value)}>
<option value=""></option>
<option value="per_100g"> 100g</option>
<option value="per_100ml"> 100ml</option>
<option value="per_serving"></option>
</select>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3">
{NUTRI_FIELDS.map((f) => (
<div key={f.key}>
<label className={label}>{f.label}</label>
<input
type="number"
step="any"
className={input}
value={nutri[f.key] || ""}
onChange={(e) => setNutri({ ...nutri, [f.key]: e.target.value })}
/>
</div>
))}
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3">
{NUTRI_FIELDS.map((f) => (
<div key={f.key}>
<label className={label}>{f.label}</label>
<input
type="number"
step="any"
className={input}
value={nutri[f.key] || ""}
onChange={(e) => setNutri({ ...nutri, [f.key]: e.target.value })}
/>
)}
{kind !== "food" && kind !== "generic" && kindFields.length > 0 && (
<div className="card p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-1">{KIND_LABEL[kind] || kind}</h2>
<p className="text-xs text-gray-400 mb-3"> * </p>
{groups.map((g) => (
<div key={g.label} className="mt-3 first:mt-0">
{g.label && <h3 className="text-xs font-semibold text-gray-400 mb-2">{g.label}</h3>}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{g.fields.map(renderField)}
</div>
</div>
))}
</div>
</div>
)}
<div className="card p-5 mt-4">
<h2 className="font-medium text-gray-700 mb-3"> URL</h2>
+15
View File
@@ -51,6 +51,20 @@ export interface Category {
name_en: string | null;
path: string;
level: number;
archive_kind: string;
}
export interface KindField {
kind: string;
field_key: string;
group_label: string;
label_zh: string;
field_type: "text" | "number" | "textarea" | "select" | "list";
unit: string | null;
options: string[] | null;
placeholder: string | null;
sort_order: number;
qualified: boolean;
}
export interface SubmissionImage {
@@ -68,6 +82,7 @@ export interface SubmissionInput {
country_of_origin?: string | null;
ingredients_text?: string | null;
nutriments?: Record<string, number> | null;
attributes?: Record<string, unknown> | null;
nutrition_basis?: string | null;
serving_size?: string | null;
nutri_score?: string | null;