feat(admin): 运营后台(登录/查看/审核编辑/补全)+ 写入API + 审计留痕
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
// 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"
|
||||
"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"`
|
||||
}
|
||||
|
||||
// ListProducts returns a paginated, optionally name/gtin-filtered list.
|
||||
func (s *Store) ListProducts(ctx context.Context, q 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
|
||||
}
|
||||
|
||||
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,
|
||||
(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 p.updated_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 := []ProductRow{}
|
||||
for rows.Next() {
|
||||
var r ProductRow
|
||||
var hasBrand, hasCat, hasNet, hasCountry, hasNutri, hasIng, hasImg bool
|
||||
var updated time.Time
|
||||
if err := rows.Scan(&r.ID, &r.GTIN, &r.Name, &r.Brand, &r.CategoryPath, &r.Status,
|
||||
&r.QualityScore, &updated, &hasBrand, &hasCat, &hasNet, &hasCountry,
|
||||
&hasNutri, &hasIng, &hasImg); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
r.UpdatedAt = updated.Format(time.RFC3339)
|
||||
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,
|
||||
}
|
||||
r.Missing = []string{}
|
||||
for _, f := range CompletenessFields {
|
||||
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"`
|
||||
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"`
|
||||
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 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,
|
||||
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.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)
|
||||
if len(nutriments) > 0 {
|
||||
_ = json.Unmarshal(nutriments, &d.Nutriments)
|
||||
}
|
||||
if d.Allergens == nil {
|
||||
d.Allergens = []string{}
|
||||
}
|
||||
if d.Additives == nil {
|
||||
d.Additives = []string{}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
d.Missing = missingFromDetail(&d)
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func missingFromDetail(d *ProductDetail) []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,
|
||||
}
|
||||
missing := []string{}
|
||||
for _, f := range CompletenessFields {
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user