feat: 数据概览/操作日志/批量操作 + 首页合格档案数
CI / Go (api) (pull_request) Successful in 13s
CI / Python (ingestion) (pull_request) Successful in 9s
CI / Migrations (postgres) (pull_request) Successful in 14s

后台新增「数据概览」(商品/合格/按状态/品牌/分类/待审核) 与「操作日志」(全局审计分页);商品列表支持多选批量改状态/分类。公开首页标题改为「天工」并展示合格档案数;新增公开接口 /api/v1/stats 与后台 /api/stats、/api/audit、/api/products/bulk。合格口径=quality_score≥0.6 且在用。

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sulaimaannaasif6866
2026-06-21 01:45:28 +00:00
parent a36700076e
commit f382c27200
15 changed files with 847 additions and 9 deletions
+73
View File
@@ -64,8 +64,11 @@ func (h *Handler) Router() http.Handler {
r.Group(func(r chi.Router) {
r.Use(h.authn.Middleware)
r.Get("/api/me", h.Me)
r.Get("/api/stats", h.Stats)
r.Get("/api/audit", h.ListAllAudit)
r.Get("/api/products", h.ListProducts)
r.Post("/api/products", h.CreateProduct)
r.Post("/api/products/bulk", h.BulkProducts)
r.Get("/api/products/{id}", h.GetProduct)
r.Put("/api/products/{id}", h.UpdateProduct)
r.Get("/api/products/{id}/audit", h.ListAudit)
@@ -203,6 +206,76 @@ func (h *Handler) CreateProduct(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, d)
}
// Stats returns the dashboard overview counters.
func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
st, err := h.store.Stats(r.Context())
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, st)
}
// ListAllAudit returns a page of the global operations audit log.
func (h *Handler) ListAllAudit(w http.ResponseWriter, r *http.Request) {
page, size := pageParams(r)
items, total, err := h.store.ListAllAudit(r.Context(), size, (page-1)*size)
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "page": page, "size": size, "total": total,
})
}
type bulkInput struct {
IDs []string `json:"ids"`
Action string `json:"action"`
Status string `json:"status"`
CategoryID *string `json:"category_id"`
}
// BulkProducts applies a status or category change to many products at once.
func (h *Handler) BulkProducts(w http.ResponseWriter, r *http.Request) {
var in bulkInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid body")
return
}
if len(in.IDs) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "未选择任何商品")
return
}
actor := auth.UserFrom(r.Context())
var (
affected int
err error
)
switch in.Action {
case "status":
affected, err = h.store.BulkSetStatus(r.Context(), actor, in.IDs, in.Status)
case "category":
affected, err = h.store.BulkSetCategory(r.Context(), actor, in.IDs, in.CategoryID)
default:
writeError(w, http.StatusBadRequest, "bad_request", "未知的批量操作")
return
}
switch {
case errors.Is(err, adminstore.ErrInvalidStatus):
writeError(w, http.StatusBadRequest, "invalid_status", "无效的状态值")
return
case errors.Is(err, adminstore.ErrInvalidParent):
writeError(w, http.StatusBadRequest, "invalid_parent", "目标分类无效")
return
case errors.Is(err, adminstore.ErrNoTargets):
writeError(w, http.StatusBadRequest, "bad_request", "未选择任何商品")
return
}
if h.handleErr(w, err) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "affected": affected})
}
// UpdateProduct applies an edit.
func (h *Handler) UpdateProduct(w http.ResponseWriter, r *http.Request) {
var in adminstore.ProductInput
+80
View File
@@ -0,0 +1,80 @@
package adminstore
import (
"context"
"errors"
)
// Bulk-operation errors.
var (
// ErrNoTargets is returned when a bulk request selects no products.
ErrNoTargets = errors.New("no products selected")
// ErrInvalidStatus is returned for an unknown product status value.
ErrInvalidStatus = errors.New("invalid status")
)
var validStatus = map[string]bool{"active": true, "merged": true, "deprecated": true}
// BulkSetStatus updates the status of every selected product in one statement.
func (s *Store) BulkSetStatus(ctx context.Context, actor string, ids []string, status string) (int, error) {
if len(ids) == 0 {
return 0, ErrNoTargets
}
if !validStatus[status] {
return 0, ErrInvalidStatus
}
ct, err := s.pool.Exec(ctx,
"UPDATE product SET status = $1 WHERE id = ANY($2)", status, ids)
if err != nil {
return 0, err
}
n := int(ct.RowsAffected())
_ = s.writeAudit(ctx, actor, "bulk_status", "product", nil,
[]string{"status"}, map[string]any{"ids": ids}, map[string]any{"status": status})
return n, nil
}
// BulkSetCategory reassigns the category of every selected product, syncing the
// GPC brick code and recomputing quality for each one.
func (s *Store) BulkSetCategory(ctx context.Context, actor string, ids []string, categoryID *string) (int, error) {
if len(ids) == 0 {
return 0, ErrNoTargets
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
var gpc *string
if categoryID != nil && *categoryID != "" {
if err := tx.QueryRow(ctx, "SELECT gpc_brick_code FROM category WHERE id = $1", *categoryID).Scan(&gpc); err != nil {
return 0, ErrInvalidParent
}
} else {
categoryID = nil
}
ct, err := tx.Exec(ctx,
"UPDATE product SET category_id = $1, gpc_brick_code = $2 WHERE id = ANY($3)",
categoryID, gpc, ids)
if err != nil {
return 0, err
}
for _, id := range ids {
if _, err := s.recomputeQualityTx(ctx, tx, id); err != nil {
return 0, err
}
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
n := int(ct.RowsAffected())
cat := ""
if categoryID != nil {
cat = *categoryID
}
_ = s.writeAudit(ctx, actor, "bulk_category", "product", nil,
[]string{"category"}, map[string]any{"ids": ids}, map[string]any{"category_id": cat})
return n, nil
}
+61
View File
@@ -0,0 +1,61 @@
package adminstore
import "context"
// QualifiedMinScore is the quality_score threshold at or above which a product
// counts as "qualified" (合格) in admin and public stats.
const QualifiedMinScore = 0.6
// AdminStats summarizes the catalog for the admin overview dashboard.
type AdminStats struct {
Products int `json:"products"`
Qualified int `json:"qualified"`
MinScore float64 `json:"min_score"`
ByStatus map[string]int `json:"by_status"`
Brands int `json:"brands"`
Categories int `json:"categories"`
Pending int `json:"pending_submissions"`
AvgQuality float64 `json:"avg_quality"`
}
// Stats gathers the dashboard counters in a handful of aggregate queries.
func (s *Store) Stats(ctx context.Context) (*AdminStats, error) {
out := &AdminStats{MinScore: QualifiedMinScore, ByStatus: map[string]int{}}
if err := s.pool.QueryRow(ctx, `
SELECT count(*),
count(*) FILTER (WHERE quality_score >= $1 AND status = 'active'),
COALESCE(avg(quality_score), 0)
FROM product`, QualifiedMinScore).Scan(&out.Products, &out.Qualified, &out.AvgQuality); err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, "SELECT status, count(*) FROM product GROUP BY status")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var st string
var n int
if err := rows.Scan(&st, &n); err != nil {
return nil, err
}
out.ByStatus[st] = n
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM brand").Scan(&out.Brands); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM category").Scan(&out.Categories); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx,
"SELECT count(*) FROM submission WHERE status = 'pending'").Scan(&out.Pending); err != nil {
return nil, err
}
return out, nil
}
@@ -0,0 +1,71 @@
package adminstore
import (
"context"
"testing"
)
func TestStatsAndBulk(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
base, err := s.Stats(ctx)
if err != nil {
t.Fatalf("stats: %v", err)
}
var p1, p2 string
if err := s.pool.QueryRow(ctx,
"INSERT INTO product (name, status) VALUES ($1,'active') RETURNING id",
"批量测试1 "+randomHex(4)).Scan(&p1); err != nil {
t.Fatalf("insert p1: %v", err)
}
if err := s.pool.QueryRow(ctx,
"INSERT INTO product (name, status) VALUES ($1,'active') RETURNING id",
"批量测试2 "+randomHex(4)).Scan(&p2); err != nil {
t.Fatalf("insert p2: %v", err)
}
t.Cleanup(func() { _, _ = s.pool.Exec(ctx, "DELETE FROM product WHERE id = ANY($1)", []string{p1, p2}) })
after, err := s.Stats(ctx)
if err != nil {
t.Fatalf("stats after: %v", err)
}
if after.Products != base.Products+2 {
t.Fatalf("product count: got %d want %d", after.Products, base.Products+2)
}
// Bulk set status to deprecated.
n, err := s.BulkSetStatus(ctx, "tester", []string{p1, p2}, "deprecated")
if err != nil || n != 2 {
t.Fatalf("bulk status: n=%d err=%v", n, err)
}
var deprecated int
if err := s.pool.QueryRow(ctx,
"SELECT count(*) FROM product WHERE id = ANY($1) AND status='deprecated'",
[]string{p1, p2}).Scan(&deprecated); err != nil {
t.Fatalf("verify: %v", err)
}
if deprecated != 2 {
t.Fatalf("expected 2 deprecated, got %d", deprecated)
}
// Invalid status rejected.
if _, err := s.BulkSetStatus(ctx, "tester", []string{p1}, "nope"); err != ErrInvalidStatus {
t.Fatalf("expected ErrInvalidStatus, got %v", err)
}
// Empty selection rejected.
if _, err := s.BulkSetStatus(ctx, "tester", nil, "active"); err != ErrNoTargets {
t.Fatalf("expected ErrNoTargets, got %v", err)
}
// Global audit log should contain the bulk_status entry.
rows, total, err := s.ListAllAudit(ctx, 10, 0)
if err != nil {
t.Fatalf("audit: %v", err)
}
if total == 0 || len(rows) == 0 {
t.Fatalf("expected audit rows, got total=%d", total)
}
}
+38
View File
@@ -481,6 +481,44 @@ type AuditEntry struct {
CreatedAt string `json:"created_at"`
}
// AuditLogRow is one global audit-log row for the operations log view.
type AuditLogRow struct {
ID string `json:"id"`
Actor string `json:"actor"`
Action string `json:"action"`
Entity string `json:"entity"`
EntityID *string `json:"entity_id"`
Fields []string `json:"fields"`
CreatedAt string `json:"created_at"`
}
// ListAllAudit returns a page of the global audit log, newest first, along with
// the total row count.
func (s *Store) ListAllAudit(ctx context.Context, limit, offset int) ([]AuditLogRow, int, error) {
var total int
if err := s.pool.QueryRow(ctx, "SELECT count(*) FROM audit_log").Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, `
SELECT id, actor, action, entity, entity_id::text, fields, created_at::text
FROM audit_log
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []AuditLogRow{}
for rows.Next() {
var e AuditLogRow
if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Entity, &e.EntityID, &e.Fields, &e.CreatedAt); err != nil {
return nil, 0, err
}
out = append(out, e)
}
return out, total, rows.Err()
}
// ListAudit returns audit history for one product, newest first.
func (s *Store) ListAudit(ctx context.Context, productID string, limit int) ([]AuditEntry, error) {
rows, err := s.pool.Query(ctx, `
+14
View File
@@ -26,6 +26,10 @@ var openAPISpec []byte
// APIVersion is the current public API version prefix.
const APIVersion = "v1"
// QualifiedMinScore is the quality_score threshold at or above which a product
// record is considered "qualified" (合格) for public stats.
const QualifiedMinScore = 0.6
const (
defaultPageSize = 20
maxPageSize = 100
@@ -85,6 +89,7 @@ func (h *Handler) Router() http.Handler {
r.Get("/brands", h.ListBrands)
r.Get("/categories", h.ListCategories)
r.Get("/sources/{id}", h.SourceByID)
r.Get("/stats", h.Stats)
})
})
@@ -122,6 +127,15 @@ func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// Stats returns catalog totals and the count of qualified records.
func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
st, err := h.store.Stats(r.Context(), QualifiedMinScore)
if h.handleErr(w, r, err) {
return
}
writeJSON(w, http.StatusOK, st)
}
// OpenAPI serves the embedded OpenAPI 3 specification for the public API.
func (h *Handler) OpenAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
+18
View File
@@ -29,6 +29,24 @@ func (s *Store) Ping(ctx context.Context) error {
return s.pool.Ping(ctx)
}
// PublicStats summarizes the public catalog for the homepage.
type PublicStats struct {
Total int `json:"total"`
Qualified int `json:"qualified"`
MinScore float64 `json:"min_score"`
}
// Stats returns active-product totals and the number of qualified records whose
// quality_score meets minScore.
func (s *Store) Stats(ctx context.Context, minScore float64) (PublicStats, error) {
st := PublicStats{MinScore: minScore}
err := s.pool.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE status = 'active'),
count(*) FILTER (WHERE status = 'active' AND quality_score >= $1)
FROM product`, minScore).Scan(&st.Total, &st.Qualified)
return st, err
}
// Barcode is one GS1 trade item number attached to a product.
type Barcode struct {
GTIN string `json:"gtin"`