Files
goods/api/internal/adminstore/adminstore.go
T
sulaimaannaasif6866 241fd38a56
CI / Go (api) (pull_request) Successful in 11s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s
feat(admin): sortable product list column headers
Click a column header (名称/品牌/条码/品类/状态/质量分) to sort asc, click
again for desc, and a third time to clear back to the default
most-recently-updated order. Sort key/direction are whitelisted server-side.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-21 08:00:36 +00:00

350 lines
11 KiB
Go

// Package adminstore is the read/write data-access layer for the admin console.
// Unlike the public store (read-only), it performs INSERT/UPDATE/DELETE and
// records field-level provenance (source = "manual") plus an audit_log entry
// for every write, then recomputes product.quality_score.
package adminstore
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned when a requested row does not exist.
var ErrNotFound = errors.New("not found")
// Store wraps a pgx pool for admin operations.
type Store struct {
pool *pgxpool.Pool
}
// New constructs an admin Store.
func New(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
// Ping verifies DB connectivity.
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
// CompletenessFields mirrors ingestion/opengoods/etl/quality.py COMPLETENESS_FIELDS.
var CompletenessFields = []string{
"name", "gtin", "brand", "category", "net_content",
"country_of_origin", "nutriments", "ingredients", "image",
}
// ---------- list ----------
// ProductRow is a list-view row for the admin product table.
type ProductRow struct {
ID string `json:"id"`
GTIN *string `json:"gtin"`
Name string `json:"name"`
Brand *string `json:"brand"`
CategoryPath *string `json:"category_path"`
Status string `json:"status"`
QualityScore float64 `json:"quality_score"`
Missing []string `json:"missing"`
UpdatedAt string `json:"updated_at"`
}
// productSortColumns whitelists the sortable list columns, mapping the API sort
// key to a SQL expression. NULLs sort last regardless of direction.
var productSortColumns = map[string]string{
"name": "p.name",
"brand": "b.name",
"gtin": "p.gtin",
"category_path": "c.path",
"status": "p.status",
"quality_score": "p.quality_score",
"updated_at": "p.updated_at",
}
// productOrderBy returns a safe ORDER BY clause for the given sort key/direction,
// falling back to the default (most recently updated first) for unknown keys.
func productOrderBy(sort, order string) string {
col, ok := productSortColumns[sort]
if !ok {
return "p.updated_at DESC"
}
dir := "ASC"
if strings.EqualFold(order, "desc") {
dir = "DESC"
}
return col + " " + dir + " NULLS LAST, p.updated_at DESC"
}
// ListProducts returns a paginated, optionally name/gtin-filtered list.
func (s *Store) ListProducts(ctx context.Context, q, sort, order string, limit, offset int) ([]ProductRow, int, error) {
args := []any{}
where := "WHERE 1=1"
if q != "" {
args = append(args, q)
where += " AND (p.name ILIKE '%' || $1 || '%' OR p.gtin ILIKE '%' || $1 || '%')"
}
var total int
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM product p "+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
qualified, err := s.kindQualifiedKeys(ctx, s.pool)
if err != nil {
return nil, 0, err
}
args = append(args, limit, offset)
sql := `
SELECT p.id, p.gtin, p.name, b.name, c.path::text, p.status, p.quality_score,
p.updated_at, COALESCE(c.archive_kind, 'generic'), p.attributes,
(p.brand_id IS NOT NULL) AS has_brand,
(p.category_id IS NOT NULL) AS has_cat,
(p.net_content_canonical IS NOT NULL) AS has_net,
(p.country_of_origin IS NOT NULL AND p.country_of_origin <> '') AS has_country,
(f.nutriments IS NOT NULL AND f.nutriments::text <> '{}') AS has_nutri,
(f.ingredients_text IS NOT NULL AND f.ingredients_text <> '') AS has_ing,
EXISTS (SELECT 1 FROM product_image pi WHERE pi.product_id = p.id) AS has_img
FROM product p
LEFT JOIN brand b ON b.id = p.brand_id
LEFT JOIN category c ON c.id = p.category_id
LEFT JOIN food_detail f ON f.product_id = p.id ` + where +
" ORDER BY " + productOrderBy(sort, order) +
" 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 := []ProductRow{}
for rows.Next() {
var r ProductRow
var hasBrand, hasCat, hasNet, hasCountry, hasNutri, hasIng, hasImg bool
var kind string
var attributes []byte
var updated time.Time
if err := rows.Scan(&r.ID, &r.GTIN, &r.Name, &r.Brand, &r.CategoryPath, &r.Status,
&r.QualityScore, &updated, &kind, &attributes,
&hasBrand, &hasCat, &hasNet, &hasCountry,
&hasNutri, &hasIng, &hasImg); err != nil {
return nil, 0, err
}
r.UpdatedAt = updated.Format(time.RFC3339)
attrs := map[string]any{}
if len(attributes) > 0 {
_ = json.Unmarshal(attributes, &attrs)
}
qkeys := qualified[kind]
present := map[string]bool{
"name": r.Name != "",
"gtin": r.GTIN != nil && *r.GTIN != "",
"brand": hasBrand,
"category": hasCat,
"net_content": hasNet,
"country_of_origin": hasCountry,
"nutriments": hasNutri,
"ingredients": hasIng,
"image": hasImg,
}
for _, k := range qkeys {
present[k] = attrPresent(attrs, k)
}
r.Missing = []string{}
for _, f := range completenessKeys(kind, qkeys) {
if !present[f] {
r.Missing = append(r.Missing, f)
}
}
out = append(out, r)
}
return out, total, rows.Err()
}
// ---------- detail ----------
// ProductImage is one image row.
type ProductImage struct {
ID string `json:"id"`
URL string `json:"url"`
Kind string `json:"kind"`
License *string `json:"license"`
}
// MSRP is one suggested-retail-price snapshot.
type MSRP struct {
ID string `json:"id"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Region string `json:"region"`
EffectiveDate *string `json:"effective_date"`
SourceURL *string `json:"source_url"`
Note *string `json:"note"`
}
// ProductDetail is the full editable view of a product.
type ProductDetail struct {
ID string `json:"id"`
GTIN *string `json:"gtin"`
Name string `json:"name"`
BrandID *string `json:"brand_id"`
Brand *string `json:"brand"`
CategoryID *string `json:"category_id"`
CategoryPath *string `json:"category_path"`
ArchiveKind string `json:"archive_kind"`
Attributes map[string]any `json:"attributes"`
NetContentValue *float64 `json:"net_content_value"`
NetContentUnit *string `json:"net_content_unit"`
CountryOfOrigin *string `json:"country_of_origin"`
Status string `json:"status"`
QualityScore float64 `json:"quality_score"`
IngredientsText *string `json:"ingredients_text"`
Allergens []string `json:"allergens"`
Additives []string `json:"additives"`
Nutriments map[string]any `json:"nutriments"`
NutritionBasis *string `json:"nutrition_basis"`
ServingSize *string `json:"serving_size"`
NutriScore *string `json:"nutri_score"`
Barcodes []Barcode `json:"barcodes"`
Images []ProductImage `json:"images"`
MSRP []MSRP `json:"msrp"`
Missing []string `json:"missing"`
UpdatedAt string `json:"updated_at"`
}
// GetProduct returns the full editable detail for one product.
func (s *Store) GetProduct(ctx context.Context, id string) (*ProductDetail, error) {
var d ProductDetail
var nutriments []byte
var attributes []byte
var updated time.Time
err := s.pool.QueryRow(ctx, `
SELECT p.id, p.gtin, p.name, p.brand_id, b.name, p.category_id, c.path::text,
COALESCE(c.archive_kind, 'generic'), p.attributes,
p.net_content_value, p.net_content_unit, p.country_of_origin, p.status,
p.quality_score, p.updated_at,
f.ingredients_text, f.allergens, f.additives, f.nutriments,
f.nutrition_basis, f.serving_size, f.nutri_score
FROM product p
LEFT JOIN brand b ON b.id = p.brand_id
LEFT JOIN category c ON c.id = p.category_id
LEFT JOIN food_detail f ON f.product_id = p.id
WHERE p.id = $1`, id).Scan(
&d.ID, &d.GTIN, &d.Name, &d.BrandID, &d.Brand, &d.CategoryID, &d.CategoryPath,
&d.ArchiveKind, &attributes,
&d.NetContentValue, &d.NetContentUnit, &d.CountryOfOrigin, &d.Status,
&d.QualityScore, &updated,
&d.IngredientsText, &d.Allergens, &d.Additives, &nutriments,
&d.NutritionBasis, &d.ServingSize, &d.NutriScore,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
d.UpdatedAt = updated.Format(time.RFC3339)
d.Attributes = map[string]any{}
if len(attributes) > 0 {
_ = json.Unmarshal(attributes, &d.Attributes)
}
if len(nutriments) > 0 {
_ = json.Unmarshal(nutriments, &d.Nutriments)
}
if d.Allergens == nil {
d.Allergens = []string{}
}
if d.Additives == nil {
d.Additives = []string{}
}
bcs, err := s.listBarcodes(ctx, id)
if err != nil {
return nil, err
}
d.Barcodes = bcs
imgs, err := s.listImages(ctx, id)
if err != nil {
return nil, err
}
d.Images = imgs
msrps, err := s.listMSRP(ctx, id)
if err != nil {
return nil, err
}
d.MSRP = msrps
qualified, err := s.kindQualifiedKeys(ctx, s.pool)
if err != nil {
return nil, err
}
d.Missing = missingFromDetail(&d, qualified[d.ArchiveKind])
return &d, nil
}
func missingFromDetail(d *ProductDetail, qualifiedAttrKeys []string) []string {
present := map[string]bool{
"name": d.Name != "",
"gtin": d.GTIN != nil && *d.GTIN != "",
"brand": d.BrandID != nil,
"category": d.CategoryID != nil,
"net_content": d.NetContentValue != nil,
"country_of_origin": d.CountryOfOrigin != nil && *d.CountryOfOrigin != "",
"nutriments": len(d.Nutriments) > 0,
"ingredients": d.IngredientsText != nil && *d.IngredientsText != "",
"image": len(d.Images) > 0,
}
for _, k := range qualifiedAttrKeys {
present[k] = attrPresent(d.Attributes, k)
}
missing := []string{}
for _, f := range completenessKeys(d.ArchiveKind, qualifiedAttrKeys) {
if !present[f] {
missing = append(missing, f)
}
}
return missing
}
func (s *Store) listImages(ctx context.Context, productID string) ([]ProductImage, error) {
rows, err := s.pool.Query(ctx,
"SELECT id, url, kind, license FROM product_image WHERE product_id = $1 ORDER BY id", productID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ProductImage{}
for rows.Next() {
var im ProductImage
if err := rows.Scan(&im.ID, &im.URL, &im.Kind, &im.License); err != nil {
return nil, err
}
out = append(out, im)
}
return out, rows.Err()
}
func (s *Store) listMSRP(ctx context.Context, productID string) ([]MSRP, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, amount, currency, region, effective_date::text, source_url, note
FROM product_msrp WHERE product_id = $1 ORDER BY effective_date DESC NULLS LAST`, productID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []MSRP{}
for rows.Next() {
var m MSRP
if err := rows.Scan(&m.ID, &m.Amount, &m.Currency, &m.Region, &m.EffectiveDate, &m.SourceURL, &m.Note); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}