feat: 数据概览/操作日志/批量操作 + 首页合格档案数
后台新增「数据概览」(商品/合格/按状态/品牌/分类/待审核) 与「操作日志」(全局审计分页);商品列表支持多选批量改状态/分类。公开首页标题改为「天工」并展示合格档案数;新增公开接口 /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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, `
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user