diff --git a/api/internal/adminhandler/handler.go b/api/internal/adminhandler/handler.go index 475225a..6a52bfe 100644 --- a/api/internal/adminhandler/handler.go +++ b/api/internal/adminhandler/handler.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/baicai2026-baicai/goods/api/internal/adminstore" + "github.com/baicai2026-baicai/goods/api/internal/apikey" "github.com/baicai2026-baicai/goods/api/internal/auth" "github.com/baicai2026-baicai/goods/api/internal/gtin" "github.com/baicai2026-baicai/goods/api/internal/ratelimit" @@ -110,6 +111,11 @@ func (h *Handler) Router() http.Handler { // admin approves them. r.Post("/api/public/submissions", h.CreateSubmission) + // Archive backflow: inventory-management software pushes products missing + // from the archive. Authenticated with a public API key; records enter the + // same moderation queue and are archived only after admin approval. + r.Post("/api/public/backflow", h.Backflow) + return r } @@ -634,6 +640,62 @@ func (h *Handler) CreateSubmission(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, map[string]string{"id": id, "status": "pending"}) } +// Backflow accepts a batch of products pushed by inventory-management software. +// It authenticates with a public API key (X-API-Key or Bearer), enqueues each +// item for admin review (deduplicating by GTIN), and returns a per-item summary. +func (h *Handler) Backflow(w http.ResponseWriter, r *http.Request) { + raw := presentedAPIKey(r) + if raw == "" { + writeError(w, http.StatusUnauthorized, "missing_api_key", "缺少 API key(请在 X-API-Key 或 Authorization: Bearer 中提供)") + return + } + if !apikey.IsWellFormed(raw) { + writeError(w, http.StatusUnauthorized, "invalid_api_key", "API key 格式无效") + return + } + if _, err := h.store.APIKeyByHash(r.Context(), apikey.Hash(raw)); err != nil { + if errors.Is(err, adminstore.ErrNotFound) { + writeError(w, http.StatusUnauthorized, "invalid_api_key", "API key 无效或已吊销") + return + } + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + + var items []adminstore.SubmissionInput + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<20)).Decode(&items); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "请求体应为商品数组 (JSON array)") + return + } + + if len(items) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "回流列表为空") + return + } + if len(items) > 1000 { + writeError(w, http.StatusBadRequest, "too_many", "单次回流最多 1000 条") + return + } + + sum, err := h.store.CreateBackflowSubmissions(r.Context(), items, realIP(r)) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + writeJSON(w, http.StatusOK, sum) +} + +// presentedAPIKey extracts a public API key from X-API-Key or a Bearer token. +func presentedAPIKey(r *http.Request) string { + if v := strings.TrimSpace(r.Header.Get("X-API-Key")); v != "" { + return v + } + if v := r.Header.Get("Authorization"); strings.HasPrefix(v, "Bearer ") { + return strings.TrimSpace(strings.TrimPrefix(v, "Bearer ")) + } + return "" +} + // ListSubmissions returns the moderation queue (admin). func (h *Handler) ListSubmissions(w http.ResponseWriter, r *http.Request) { status := r.URL.Query().Get("status") diff --git a/api/internal/adminstore/backflow.go b/api/internal/adminstore/backflow.go new file mode 100644 index 0000000..a6fcbcb --- /dev/null +++ b/api/internal/adminstore/backflow.go @@ -0,0 +1,145 @@ +package adminstore + +import ( + "context" + "encoding/json" + "errors" + "strings" + + "github.com/jackc/pgx/v5" +) + +// backflowSource is the value stored in submission.payload->>'source' for +// records pushed by inventory-management software via the backflow API. +const backflowSource = "backflow" + +// APIKeyAuth is the minimal key metadata needed to authenticate a backflow +// caller. Only active (non-revoked) keys resolve. +type APIKeyAuth struct { + ID string + Name string +} + +// APIKeyByHash returns the active key matching a SHA-256 hash, or ErrNotFound +// when no such active key exists. Used to authenticate machine callers (e.g. +// the backflow endpoint) with the same public API keys issued to API users. +func (s *Store) APIKeyByHash(ctx context.Context, hash string) (*APIKeyAuth, error) { + var k APIKeyAuth + err := s.pool.QueryRow(ctx, + "SELECT id, name FROM api_key WHERE key_hash = $1 AND revoked_at IS NULL", hash, + ).Scan(&k.ID, &k.Name) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &k, nil +} + +// BackflowResult reports the outcome of one item in a backflow batch. +type BackflowResult struct { + GTIN *string `json:"gtin"` + Name string `json:"name"` + Status string `json:"status"` // queued | exists | duplicate | invalid + ID string `json:"id,omitempty"` // submission id when queued + Reason string `json:"reason,omitempty"` +} + +// BackflowSummary aggregates a backflow batch outcome. +type BackflowSummary struct { + Total int `json:"total"` + Queued int `json:"queued"` + Exists int `json:"exists"` + Duplicate int `json:"duplicate"` + Invalid int `json:"invalid"` + Results []BackflowResult `json:"results"` +} + +// CreateBackflowSubmissions enqueues products pushed by inventory software for +// admin review. Each item is deduplicated by GTIN: items whose barcode already +// matches an archived product are skipped ("exists"), and items that duplicate +// a pending submission are skipped ("duplicate"). Accepted items are tagged +// with source="backflow" and enter the same moderation queue as public +// contributions; approval creates the product exactly as ApproveSubmission does. +// +// Items are processed independently: a bad item never rolls back accepted ones. +func (s *Store) CreateBackflowSubmissions(ctx context.Context, items []SubmissionInput, remoteIP string) (BackflowSummary, error) { + sum := BackflowSummary{Total: len(items), Results: make([]BackflowResult, 0, len(items))} + + for _, in := range items { + in.Name = strings.TrimSpace(in.Name) + res := BackflowResult{Name: in.Name} + + if in.GTIN != nil { + g := strings.TrimSpace(*in.GTIN) + if g == "" { + in.GTIN = nil + } else { + in.GTIN = &g + res.GTIN = &g + } + } + + if in.Name == "" { + res.Status = "invalid" + res.Reason = "商品名称不能为空" + sum.Invalid++ + sum.Results = append(sum.Results, res) + continue + } + + if in.GTIN != nil { + // Already archived: backflow only carries products we don't have yet. + var pid string + err := s.pool.QueryRow(ctx, "SELECT id FROM product WHERE gtin = $1", *in.GTIN).Scan(&pid) + if err == nil { + res.Status = "exists" + res.Reason = "该条码商品已收录" + sum.Exists++ + sum.Results = append(sum.Results, res) + continue + } else if !errors.Is(err, pgx.ErrNoRows) { + return sum, err + } + + // Collapse repeated auto-pushes of the same barcode in the queue. + var sid string + err = s.pool.QueryRow(ctx, + "SELECT id FROM submission WHERE gtin = $1 AND status = 'pending' LIMIT 1", *in.GTIN).Scan(&sid) + if err == nil { + res.Status = "duplicate" + res.Reason = "已有待审核的同条码回流记录" + res.ID = sid + sum.Duplicate++ + sum.Results = append(sum.Results, res) + continue + } else if !errors.Is(err, pgx.ErrNoRows) { + return sum, err + } + } + + src := backflowSource + in.Source = &src + + payload, err := json.Marshal(in) + if err != nil { + return sum, err + } + + var id string + err = s.pool.QueryRow(ctx, ` +INSERT INTO submission (gtin, name, payload, submitter_name, submitter_contact, note, remote_ip) +VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, + in.GTIN, in.Name, payload, in.SubmitterName, in.SubmitterContact, in.Note, remoteIP).Scan(&id) + if err != nil { + return sum, err + } + res.Status = "queued" + res.ID = id + sum.Queued++ + sum.Results = append(sum.Results, res) + } + + return sum, nil +} diff --git a/api/internal/adminstore/submission.go b/api/internal/adminstore/submission.go index 3ab94c0..c504008 100644 --- a/api/internal/adminstore/submission.go +++ b/api/internal/adminstore/submission.go @@ -40,6 +40,10 @@ type SubmissionInput struct { SubmitterName *string `json:"submitter_name"` SubmitterContact *string `json:"submitter_contact"` Note *string `json:"note"` + // Source tags the origin of the submission, stored inside the payload so no + // schema change is needed. Empty means the default public contribution + // ("community"); "backflow" marks records pushed by inventory software. + Source *string `json:"source,omitempty"` } // SubmissionRow is a queue-list row for the admin review table. @@ -49,6 +53,7 @@ type SubmissionRow struct { Name string `json:"name"` Status string `json:"status"` SubmitterName *string `json:"submitter_name"` + Source *string `json:"source"` Matched bool `json:"matched"` CreatedAt string `json:"created_at"` ReviewedAt *string `json:"reviewed_at"` @@ -129,8 +134,8 @@ func (s *Store) ListSubmissions(ctx context.Context, status string, limit, offse args = append(args, limit, offset) sql := ` -SELECT id, gtin, name, status, submitter_name, (target_product_id IS NOT NULL), - created_at, reviewed_at +SELECT id, gtin, name, status, submitter_name, NULLIF(payload->>'source',''), + (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)) @@ -146,7 +151,7 @@ FROM submission ` + where + 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 { + if err := rows.Scan(&r.ID, &r.GTIN, &r.Name, &r.Status, &r.SubmitterName, &r.Source, &r.Matched, &created, &reviewed); err != nil { return nil, 0, err } r.CreatedAt = created.Format(time.RFC3339) diff --git a/docs/api.md b/docs/api.md index 1329e1f..896585b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -112,6 +112,64 @@ curl "https://goods.tangshasha.com/api/v1/products/barcode/5449000000996" ### `GET /sources/{id}` — 数据来源 +## 档案回流(写接口,需 API Key) + +> 仅供进销存等机器调用方使用:把档案里**尚未收录**的商品批量回流到站点,进入人工审核队列,审核通过后才会收录。**必须携带 API Key**(与上文同一类 `og_live_` 密钥),不会直接写入商品。 + +### `POST /api/public/backflow` — 批量回流未收录商品 + +- 鉴权:请求头携带 `X-API-Key: og_live_xxxxxxxx`(或 `Authorization: Bearer og_live_xxxxxxxx`)。缺失/无效/已吊销返回 `401`。 +- 请求体:商品对象**数组**(与公众投稿同结构),单次最多 `1000` 条。常用字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `name` | 是 | 商品名称 | +| `gtin` | 否 | 条码(GTIN)。强烈建议提供,用于去重 | +| `brand_name` | 否 | 品牌名 | +| `category_id` | 否 | 品类编码,如 `food.beverages` | +| `net_content_value` / `net_content_unit` | 否 | 净含量数值 / 单位 | +| `country_of_origin` | 否 | 产地 | +| `ingredients_text` | 否 | 配料表 | +| `nutriments` | 否 | 营养成分对象 | +| `msrp` | 否 | 零售价快照数组,元素含 `amount`/`currency`/`region`/`effective_date` | +| `note` | 否 | 备注 | + +> 来源会自动标记为 `source="backflow"`,在后台审核队列中与公众投稿区分,无需调用方提供。 + +- 去重(按 `gtin` 逐条判断,互不影响): + - 该条码已收录为商品 → `exists`,跳过; + - 已存在同条码的待审核回流 → `duplicate`,跳过(避免反复刷队列); + - 否则入队 → `queued`(status=`pending`,等待后台审核); + - 名称为空等 → `invalid`。 + +```bash +curl -X POST "https://goods.tangshasha.com/api/public/backflow" \ + -H "X-API-Key: og_live_xxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '[ + {"name":"某某牛奶 250ml","gtin":"6901234567890","brand_name":"某品牌", + "net_content_value":250,"net_content_unit":"ml", + "msrp":[{"amount":3.5,"currency":"CNY","region":"CN"}]}, + {"name":"已收录商品","gtin":"5449000000996"} + ]' +``` + +```json +{ + "total": 2, + "queued": 1, + "exists": 1, + "duplicate": 0, + "invalid": 0, + "results": [ + { "gtin": "6901234567890", "name": "某某牛奶 250ml", "status": "queued", "id": "" }, + { "gtin": "5449000000996", "name": "已收录商品", "status": "exists", "reason": "该条码商品已收录" } + ] +} +``` + +审核通过后,系统按提交内容**新建商品**;若审核时该条码已存在商品,则**补全**到已有商品(逻辑与公众投稿一致)。 + ## 免责声明 数据可能存在误差或滞后,按「现状」提供,不构成医疗/购买建议。商品资料版权归各原始来源所有,请遵循其许可(如 OpenFoodFacts 的 ODbL),引用时请注明天工商品档案公共仓及原始来源。 diff --git a/public-frontend/src/components/ApiDocs.tsx b/public-frontend/src/components/ApiDocs.tsx index 68b5372..3cbfc56 100644 --- a/public-frontend/src/components/ApiDocs.tsx +++ b/public-frontend/src/components/ApiDocs.tsx @@ -343,6 +343,41 @@ curl -H "Authorization: Bearer og_live_xxxxxxxx" ${BASE}/products/search?q=牛 }`} /> + " }, + { "gtin": "5449000000996", "name": "已收录商品", + "status": "exists", "reason": "该条码商品已收录" } + ] +}`} + /> +