916bdd9c7c
- 新增 category.archive_kind 与 kind_field 字段模板表(迁移 0010) - 种子 electronics 字段模板 + 3C 品类树(手机/笔记本/平板等) - 后端按档案模式动态计算完整度/合格:食品沿用 food_detail, 其它模式走 product.attributes + kind_field - 新增 GET /api/kind-fields?kind= 接口 - 后台编辑页按品类模式动态渲染规格参数表单 - 公开详情页/接口输出带标签的规格表 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
160 lines
3.9 KiB
Go
160 lines
3.9 KiB
Go
package adminstore
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"math"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// Quality weights mirror ingestion/opengoods/etl/quality.py.
|
|
const (
|
|
wCompleteness = 0.4
|
|
wSourceTrust = 0.3
|
|
wAgreement = 0.2
|
|
wFreshness = 0.1
|
|
)
|
|
|
|
// queryer is satisfied by both *pgxpool.Pool and pgx.Tx.
|
|
type queryer interface {
|
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
|
}
|
|
|
|
func agreementFromSources(n int) float64 {
|
|
switch {
|
|
case n <= 1:
|
|
return 0.5
|
|
case n == 2:
|
|
return 0.8
|
|
default:
|
|
return 1.0
|
|
}
|
|
}
|
|
|
|
func freshnessFromAge(ageDays *float64) float64 {
|
|
if ageDays == nil {
|
|
return 0.5
|
|
}
|
|
d := *ageDays
|
|
switch {
|
|
case d <= 30:
|
|
return 1.0
|
|
case d <= 180:
|
|
return 0.8
|
|
case d <= 365:
|
|
return 0.6
|
|
case d <= 730:
|
|
return 0.4
|
|
default:
|
|
return 0.2
|
|
}
|
|
}
|
|
|
|
func (s *Store) computeQuality(ctx context.Context, q queryer, productID string) (float64, error) {
|
|
var name, country *string
|
|
var gtin *string
|
|
var brandID, categoryID *string
|
|
var netCanonical *float64
|
|
var ingredients *string
|
|
var hasNutri, hasImage bool
|
|
var kind string
|
|
var attributes []byte
|
|
err := q.QueryRow(ctx, `
|
|
SELECT p.name, p.gtin, p.brand_id, p.category_id, p.net_content_canonical,
|
|
p.country_of_origin, COALESCE(c.archive_kind, 'generic'), p.attributes,
|
|
f.ingredients_text,
|
|
(f.nutriments IS NOT NULL AND f.nutriments::text <> '{}'),
|
|
EXISTS (SELECT 1 FROM product_image pi WHERE pi.product_id = p.id)
|
|
FROM product p
|
|
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`, productID).Scan(
|
|
&name, >in, &brandID, &categoryID, &netCanonical, &country,
|
|
&kind, &attributes, &ingredients, &hasNutri, &hasImage)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
attrs := map[string]any{}
|
|
if len(attributes) > 0 {
|
|
_ = json.Unmarshal(attributes, &attrs)
|
|
}
|
|
qualified, err := s.kindQualifiedKeys(ctx, s.pool)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
qkeys := qualified[kind]
|
|
|
|
known := map[string]bool{
|
|
"name": name != nil && *name != "",
|
|
"gtin": gtin != nil && *gtin != "",
|
|
"brand": brandID != nil,
|
|
"category": categoryID != nil,
|
|
"net_content": netCanonical != nil,
|
|
"country_of_origin": country != nil && *country != "",
|
|
"nutriments": hasNutri,
|
|
"ingredients": ingredients != nil && *ingredients != "",
|
|
"image": hasImage,
|
|
}
|
|
for _, k := range qkeys {
|
|
known[k] = attrPresent(attrs, k)
|
|
}
|
|
keys := completenessKeys(kind, qkeys)
|
|
present := 0
|
|
for _, k := range keys {
|
|
if known[k] {
|
|
present++
|
|
}
|
|
}
|
|
completeness := float64(present) / float64(len(keys))
|
|
|
|
var sourceCount int
|
|
var sourceTrust *float64
|
|
var lastFetched *time.Time
|
|
err = q.QueryRow(ctx, `
|
|
SELECT count(DISTINCT ps.source_id), COALESCE(max(s.trust_weight),0), max(ps.fetched_at)
|
|
FROM product_source ps LEFT JOIN source s ON s.id = ps.source_id
|
|
WHERE ps.product_id = $1`, productID).Scan(&sourceCount, &sourceTrust, &lastFetched)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
trust := 0.0
|
|
if sourceTrust != nil {
|
|
trust = *sourceTrust
|
|
}
|
|
|
|
var ageDays *float64
|
|
if lastFetched != nil {
|
|
d := time.Since(*lastFetched).Hours() / 24.0
|
|
if d < 0 {
|
|
d = 0
|
|
}
|
|
ageDays = &d
|
|
}
|
|
|
|
raw := wCompleteness*completeness + wSourceTrust*trust +
|
|
wAgreement*agreementFromSources(sourceCount) + wFreshness*freshnessFromAge(ageDays)
|
|
raw = math.Max(0, math.Min(1, raw))
|
|
return math.Round(raw*1000) / 1000, nil
|
|
}
|
|
|
|
func (s *Store) recomputeQualityTx(ctx context.Context, tx pgx.Tx, productID string) (float64, error) {
|
|
v, err := s.computeQuality(ctx, tx, productID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
_, err = tx.Exec(ctx, "UPDATE product SET quality_score=$1 WHERE id=$2", v, productID)
|
|
return v, err
|
|
}
|
|
|
|
func (s *Store) recomputeQuality(ctx context.Context, productID string) (float64, error) {
|
|
v, err := s.computeQuality(ctx, s.pool, productID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
_, err = s.pool.Exec(ctx, "UPDATE product SET quality_score=$1 WHERE id=$2", v, productID)
|
|
return v, err
|
|
}
|