chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
@@ -0,0 +1,95 @@
package admindashboard
import "time"
type DashboardDTO struct {
Metrics MetricsDTO `json:"metrics"`
Pending PendingDTO `json:"pending"`
ListingDaily ListingDailyOverviewDTO `json:"listing_daily"`
ManualEntryToday []ManualEntryAgentStatsDTO `json:"manual_entry_today"`
RecentOrders []RecentOrderDTO `json:"recent_orders"`
RecentDisputes []RecentDisputeDTO `json:"recent_disputes"`
GeneratedAt time.Time `json:"generated_at"`
}
type MetricsDTO struct {
TotalUsers int64 `json:"total_users"`
VerifiedUsers int64 `json:"verified_users"`
TotalListings int64 `json:"total_listings"`
PublishedListings int64 `json:"published_listings"`
TotalOrders int64 `json:"total_orders"`
RentingOrders int64 `json:"renting_orders"`
TodayOrders int64 `json:"today_orders"`
TodayLedgerAmountCent int64 `json:"today_ledger_amount_cent"`
}
// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。
type ListingDailyOverviewDTO struct {
Today ListingDayStatsDTO `json:"today"`
Trend []ListingDayStatsDTO `json:"trend"`
TodayChannels []ListingChannelDayStatsDTO `json:"today_channels"`
ChannelTrend []ListingChannelDayStatsDTO `json:"channel_trend"`
TodayUploaders []ListingUploaderDayStatsDTO `json:"today_uploaders"`
UploaderTrend []ListingUploaderDayStatsDTO `json:"uploader_trend"`
Days int `json:"days"`
Timezone string `json:"timezone"`
}
type ListingDayStatsDTO struct {
Date string `json:"date"` // YYYY-MM-DD(上海时区)
PublishedCount int64 `json:"published_count"`
ActiveOfflineCount int64 `json:"active_offline_count"` // 号主 + 后台主动下架
TradeLeaveCount int64 `json:"trade_leave_count"` // rented / completed / sealed / 交易侧归档下架
}
type ListingChannelDayStatsDTO struct {
Date string `json:"date"` // YYYY-MM-DD(上海时区)
SourceChannel string `json:"source_channel"`
PublishedCount int64 `json:"published_count"`
ActiveOfflineCount int64 `json:"active_offline_count"`
TradeLeaveCount int64 `json:"trade_leave_count"`
}
type ListingUploaderDayStatsDTO struct {
Date string `json:"date"` // YYYY-MM-DD(上海时区)
UploaderID uint64 `json:"uploader_id"`
UploaderName string `json:"uploader_name"`
UploadCount int64 `json:"upload_count"`
}
// ManualEntryAgentStatsDTO 今日客服手动录单统计(按客服聚合)。
type ManualEntryAgentStatsDTO struct {
AdminID uint64 `json:"admin_id"`
AdminName string `json:"admin_name"`
Count int64 `json:"count"`
}
type PendingDTO struct {
ListingReviews int64 `json:"listing_reviews"`
Disputes int64 `json:"disputes"`
PendingHandoffs int64 `json:"pending_handoffs"`
PendingReturnConfirms int64 `json:"pending_return_confirms"`
}
type RecentOrderDTO struct {
ID uint64 `json:"id"`
OrderNo string `json:"order_no"`
Title string `json:"title"`
RenterID uint64 `json:"renter_id"`
OwnerID uint64 `json:"owner_id"`
Status string `json:"status"`
RentAmountCent int64 `json:"rent_amount_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
CreatedAt time.Time `json:"created_at"`
}
type RecentDisputeDTO struct {
ID uint64 `json:"id"`
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
Title string `json:"title"`
Type string `json:"type"`
Status string `json:"status"`
InitiatorID uint64 `json:"initiator_id"`
CreatedAt time.Time `json:"created_at"`
}
@@ -0,0 +1,37 @@
package admindashboard
import (
"errors"
"net/http"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) Summary(c *gin.Context) {
item, err := h.service.Summary(c.Request.Context())
if err != nil {
writeDashboardError(c, err)
return
}
response.OK(c, item)
}
func writeDashboardError(c *gin.Context, err error) {
response.RecordError(c, err)
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "数据库未连接")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "后台仪表盘服务暂时不可用")
}
}
@@ -0,0 +1,418 @@
package admindashboard
import (
"context"
"sort"
"time"
"hfb_sys/backend/internal/listingstatus"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/timeutil"
"gorm.io/gorm"
)
const (
listingDailyTrendDays = 7
listingChannelWebsite = "站内发布"
listingChannelExternalUnknown = "未填写"
)
type Repository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) {
db := r.db.WithContext(ctx)
loc := timeutil.ShanghaiLocation()
now := timeutil.ShanghaiNow()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
metrics := MetricsDTO{}
pending := PendingDTO{}
if err := db.Model(&model.User{}).Count(&metrics.TotalUsers).Error; err != nil {
return nil, err
}
if err := db.Model(&model.User{}).Where("realname_status = ?", "verified").Count(&metrics.VerifiedUsers).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalListing{}).Count(&metrics.TotalListings).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalListing{}).Where("status = ? AND review_status = ?", "published", "approved").Count(&metrics.PublishedListings).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalOrder{}).Count(&metrics.TotalOrders).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalOrder{}).Where("status = ?", "renting").Count(&metrics.RentingOrders).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalOrder{}).Where("created_at >= ?", today).Count(&metrics.TodayOrders).Error; err != nil {
return nil, err
}
if err := db.Model(&model.WalletLedger{}).
Select("COALESCE(SUM(amount_cent), 0)").
Where("created_at >= ?", today).
Scan(&metrics.TodayLedgerAmountCent).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalListing{}).Where("review_status = ?", "pending").Count(&pending.ListingReviews).Error; err != nil {
return nil, err
}
if err := db.Model(&model.Dispute{}).Where("status IN ?", []string{"open", "processing"}).Count(&pending.Disputes).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalOrder{}).Where("status = ? AND handoff_status IN ?", "pending_handoff", []string{"pending_owner", "pending_renter_confirm"}).Count(&pending.PendingHandoffs).Error; err != nil {
return nil, err
}
if err := db.Model(&model.RentalOrder{}).Where("status IN ?", []string{"pending_checkout_confirm", "pending_checkout_accept"}).Count(&pending.PendingReturnConfirms).Error; err != nil {
return nil, err
}
listingDaily, err := r.listingDailyOverview(ctx, now, listingDailyTrendDays)
if err != nil {
return nil, err
}
manualEntryToday, err := r.manualEntryToday(ctx, now)
if err != nil {
return nil, err
}
recentOrders, err := r.recentOrders(ctx)
if err != nil {
return nil, err
}
recentDisputes, err := r.recentDisputes(ctx)
if err != nil {
return nil, err
}
return &DashboardDTO{
Metrics: metrics,
Pending: pending,
ListingDaily: listingDaily,
ManualEntryToday: manualEntryToday,
RecentOrders: recentOrders,
RecentDisputes: recentDisputes,
GeneratedAt: now,
}, nil
}
func (r *Repository) listingDailyOverview(ctx context.Context, now time.Time, days int) (ListingDailyOverviewDTO, error) {
if days <= 0 {
days = listingDailyTrendDays
}
loc := timeutil.ShanghaiLocation()
now = now.In(loc)
// 右开区间 [start, end)
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1)
start := end.AddDate(0, 0, -days)
type eventRow struct {
EventType string
Source string
SourceChannel string
CreatedAt time.Time
}
events := make([]eventRow, 0)
db := r.db.WithContext(ctx)
uploadSource := db.Table("listing_uploads").
Select("listing_id, MAX(id) AS upload_id, MAX(NULLIF(source_channel, '')) AS source_channel").
Where("listing_id IS NOT NULL").
Group("listing_id")
if err := db.Table("listing_status_events AS e").
Select(`e.event_type, e.source, e.created_at,
CASE
WHEN lu.upload_id IS NULL THEN ?
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
ELSE lu.source_channel
END AS source_channel`, listingChannelWebsite, listingChannelExternalUnknown).
Joins("LEFT JOIN (?) AS lu ON lu.listing_id = e.listing_id", uploadSource).
Where("e.created_at >= ? AND e.created_at < ?", start, end).
Scan(&events).Error; err != nil {
return ListingDailyOverviewDTO{}, err
}
uploads := make([]listingUploaderRow, 0)
if err := db.Table("listing_uploads").
Select("COALESCE(matched_admin_id, 0) AS uploader_id, uploader_name, created_at").
Where("listing_id IS NOT NULL").
Where("created_at >= ? AND created_at < ?", start, end).
Scan(&uploads).Error; err != nil {
return ListingDailyOverviewDTO{}, err
}
byDay := make(map[string]*listingDailyBucket, days)
byChannel := make(map[listingChannelKey]*listingDailyBucket)
byUploader := make(map[listingUploaderKey]int64)
for _, ev := range events {
key := ev.CreatedAt.In(loc).Format("2006-01-02")
b := byDay[key]
if b == nil {
b = &listingDailyBucket{}
byDay[key] = b
}
if !addListingDailyEvent(b, ev.EventType, ev.Source) {
continue
}
channel := ev.SourceChannel
channelBucketKey := listingChannelKey{date: key, sourceChannel: channel}
cb := byChannel[channelBucketKey]
if cb == nil {
cb = &listingDailyBucket{}
byChannel[channelBucketKey] = cb
}
addListingDailyEvent(cb, ev.EventType, ev.Source)
}
for _, upload := range uploads {
key := upload.CreatedAt.In(loc).Format("2006-01-02")
uploaderKey := listingUploaderKey{date: key, uploaderID: upload.UploaderID, uploaderName: upload.UploaderName}
byUploader[uploaderKey]++
}
trend := make([]ListingDayStatsDTO, 0, days)
for i := days - 1; i >= 0; i-- {
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -i)
key := day.Format("2006-01-02")
item := ListingDayStatsDTO{Date: key}
if b := byDay[key]; b != nil {
item.PublishedCount = b.published
item.ActiveOfflineCount = b.activeOffline
item.TradeLeaveCount = b.tradeLeave
}
trend = append(trend, item)
}
todayStats := ListingDayStatsDTO{Date: now.Format("2006-01-02")}
if len(trend) > 0 {
todayStats = trend[len(trend)-1]
}
channelTrend := listingChannelTrend(byChannel)
uploaderTrend := listingUploaderTrend(byUploader)
todayChannels := make([]ListingChannelDayStatsDTO, 0)
for _, item := range channelTrend {
if item.Date == todayStats.Date {
todayChannels = append(todayChannels, item)
}
}
todayUploaders := make([]ListingUploaderDayStatsDTO, 0)
for _, item := range uploaderTrend {
if item.Date == todayStats.Date {
todayUploaders = append(todayUploaders, item)
}
}
return ListingDailyOverviewDTO{
Today: todayStats,
Trend: trend,
TodayChannels: todayChannels,
ChannelTrend: channelTrend,
TodayUploaders: todayUploaders,
UploaderTrend: uploaderTrend,
Days: days,
Timezone: "Asia/Shanghai",
}, nil
}
type listingUploaderRow struct {
UploaderID uint64
UploaderName string
CreatedAt time.Time
}
type listingChannelKey struct {
date string
sourceChannel string
}
type listingUploaderKey struct {
date string
uploaderID uint64
uploaderName string
}
type listingDailyBucket struct {
published int64
activeOffline int64
tradeLeave int64
}
func addListingDailyEvent(b *listingDailyBucket, eventType, source string) bool {
switch {
case eventType == listingstatus.EventPublished:
b.published++
return true
case isActiveOfflineEvent(eventType, source):
b.activeOffline++
return true
case isTradeLeaveEvent(eventType, source):
b.tradeLeave++
return true
default:
return false
}
}
func listingChannelTrend(rows map[listingChannelKey]*listingDailyBucket) []ListingChannelDayStatsDTO {
keys := make([]listingChannelKey, 0, len(rows))
for key := range rows {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].date != keys[j].date {
return keys[i].date < keys[j].date
}
leftRank := listingChannelRank(keys[i].sourceChannel)
rightRank := listingChannelRank(keys[j].sourceChannel)
if leftRank != rightRank {
return leftRank < rightRank
}
return keys[i].sourceChannel < keys[j].sourceChannel
})
items := make([]ListingChannelDayStatsDTO, 0, len(keys))
for _, key := range keys {
b := rows[key]
items = append(items, ListingChannelDayStatsDTO{
Date: key.date,
SourceChannel: key.sourceChannel,
PublishedCount: b.published,
ActiveOfflineCount: b.activeOffline,
TradeLeaveCount: b.tradeLeave,
})
}
return items
}
func listingUploaderTrend(rows map[listingUploaderKey]int64) []ListingUploaderDayStatsDTO {
keys := make([]listingUploaderKey, 0, len(rows))
for key := range rows {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].date != keys[j].date {
return keys[i].date < keys[j].date
}
if rows[keys[i]] != rows[keys[j]] {
return rows[keys[i]] > rows[keys[j]]
}
if keys[i].uploaderName != keys[j].uploaderName {
return keys[i].uploaderName < keys[j].uploaderName
}
return keys[i].uploaderID < keys[j].uploaderID
})
items := make([]ListingUploaderDayStatsDTO, 0, len(keys))
for _, key := range keys {
items = append(items, ListingUploaderDayStatsDTO{
Date: key.date,
UploaderID: key.uploaderID,
UploaderName: key.uploaderName,
UploadCount: rows[key],
})
}
return items
}
func listingChannelRank(channel string) int {
switch channel {
case "咸鱼":
return 10
case "淘宝":
return 20
case "京东":
return 30
case "QQ":
return 40
case "微信":
return 50
case listingChannelExternalUnknown:
return 90
case listingChannelWebsite:
return 100
default:
return 80
}
}
func isActiveOfflineEvent(eventType, source string) bool {
return eventType == listingstatus.EventOffline &&
(source == listingstatus.SourceSeller || source == listingstatus.SourceAdmin)
}
func isTradeLeaveEvent(eventType, source string) bool {
switch eventType {
case listingstatus.EventRented, listingstatus.EventCompleted, listingstatus.EventSealed:
return true
case listingstatus.EventOffline:
return source == listingstatus.SourceOrder ||
source == listingstatus.SourceDispute ||
source == listingstatus.SourcePickup ||
source == listingstatus.SourceSystem
default:
return false
}
}
// manualEntryToday 统计今日(北京时间自然日)各客服的手动录单数量。
// 录单表是后来新增的功能表,若迁移尚未执行(表不存在)则降级为空统计,
// 避免连带整个仪表盘不可用。
func (r *Repository) manualEntryToday(ctx context.Context, now time.Time) ([]ManualEntryAgentStatsDTO, error) {
if r.db == nil || !r.db.Migrator().HasTable("cs_manual_entries") {
return []ManualEntryAgentStatsDTO{}, nil
}
loc := timeutil.ShanghaiLocation()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
type row struct {
AdminID uint64
AdminName string
Count int64
}
rows := make([]row, 0)
if err := r.db.WithContext(ctx).Table("cs_manual_entries").
Select("admin_id, MAX(admin_name) AS admin_name, COUNT(*) AS count").
Where("created_at >= ? AND created_at < ?", start, end).
Group("admin_id").
Scan(&rows).Error; err != nil {
return nil, err
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Count != rows[j].Count {
return rows[i].Count > rows[j].Count
}
return rows[i].AdminName < rows[j].AdminName
})
items := make([]ManualEntryAgentStatsDTO, 0, len(rows))
for _, item := range rows {
items = append(items, ManualEntryAgentStatsDTO{
AdminID: item.AdminID,
AdminName: item.AdminName,
Count: item.Count,
})
}
return items, nil
}
func (r *Repository) recentOrders(ctx context.Context) ([]RecentOrderDTO, error) {
rows := make([]RecentOrderDTO, 0)
err := r.db.WithContext(ctx).Table("rental_orders AS o").
Select("o.id, o.order_no, a.title, o.renter_id, o.owner_id, o.status, o.rent_amount_cent, o.deposit_amount_cent, o.created_at").
Joins("JOIN game_accounts AS a ON a.id = o.account_id").
Order("o.id DESC").
Limit(8).
Scan(&rows).Error
return rows, err
}
func (r *Repository) recentDisputes(ctx context.Context) ([]RecentDisputeDTO, error) {
rows := make([]RecentDisputeDTO, 0)
err := r.db.WithContext(ctx).Table("disputes AS d").
Select("d.id, d.order_id, o.order_no, a.title, d.type, d.status, d.initiator_id, d.created_at").
Joins("JOIN rental_orders AS o ON o.id = d.order_id").
Joins("JOIN game_accounts AS a ON a.id = o.account_id").
Order("d.id DESC").
Limit(8).
Scan(&rows).Error
return rows, err
}
@@ -0,0 +1,108 @@
package admindashboard
import (
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/listingstatus"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/timeutil"
)
func TestListingDailyOverviewGroupsBySourceChannel(t *testing.T) {
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db, &model.ListingStatusEvent{}, &model.ListingUpload{}); err != nil {
t.Fatalf("AutoMigrate() error = %v", err)
}
loc := timeutil.ShanghaiLocation()
now := time.Date(2026, 7, 25, 12, 0, 0, 0, loc)
listingID1 := uint64(1)
listingID2 := uint64(2)
listingID4 := uint64(4)
uploads := []model.ListingUpload{
{ListingID: &listingID1, UploaderName: "客服1", SourceChannel: "淘宝"},
{ListingID: &listingID2, UploaderName: "客服2", SourceChannel: "微信"},
{ListingID: &listingID4, UploaderName: "客服3"},
}
if err := db.Create(&uploads).Error; err != nil {
t.Fatalf("create uploads error = %v", err)
}
events := []model.ListingStatusEvent{
{ListingID: listingID1, EventType: listingstatus.EventPublished, Source: listingstatus.SourceReview, CreatedAt: now},
{ListingID: listingID1, EventType: listingstatus.EventOffline, Source: listingstatus.SourceAdmin, CreatedAt: now.Add(time.Minute)},
{ListingID: listingID2, EventType: listingstatus.EventRented, Source: listingstatus.SourceOrder, CreatedAt: now.Add(2 * time.Minute)},
{ListingID: 3, EventType: listingstatus.EventPublished, Source: listingstatus.SourceSeller, CreatedAt: now.Add(3 * time.Minute)},
{ListingID: listingID4, EventType: listingstatus.EventPublished, Source: listingstatus.SourceReview, CreatedAt: now.Add(4 * time.Minute)},
}
if err := db.Create(&events).Error; err != nil {
t.Fatalf("create events error = %v", err)
}
overview, err := NewRepository(db).listingDailyOverview(t.Context(), now, 1)
if err != nil {
t.Fatalf("listingDailyOverview() error = %v", err)
}
if overview.Today.PublishedCount != 3 || overview.Today.ActiveOfflineCount != 1 || overview.Today.TradeLeaveCount != 1 {
t.Fatalf("today stats = %#v", overview.Today)
}
statsByChannel := make(map[string]ListingChannelDayStatsDTO)
for _, item := range overview.TodayChannels {
statsByChannel[item.SourceChannel] = item
}
assertChannelStats(t, statsByChannel["淘宝"], 1, 1, 0)
assertChannelStats(t, statsByChannel["微信"], 0, 0, 1)
assertChannelStats(t, statsByChannel[listingChannelWebsite], 1, 0, 0)
assertChannelStats(t, statsByChannel[listingChannelExternalUnknown], 1, 0, 0)
}
func assertChannelStats(t *testing.T, item ListingChannelDayStatsDTO, published, offline, tradeLeave int64) {
t.Helper()
if item.PublishedCount != published || item.ActiveOfflineCount != offline || item.TradeLeaveCount != tradeLeave {
t.Fatalf("channel %q stats = %#v, want published=%d offline=%d tradeLeave=%d", item.SourceChannel, item, published, offline, tradeLeave)
}
}
func TestListingDailyOverviewGroupsExternalUploadsByUploader(t *testing.T) {
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db, &model.ListingStatusEvent{}, &model.ListingUpload{}); err != nil {
t.Fatalf("AutoMigrate() error = %v", err)
}
loc := timeutil.ShanghaiLocation()
now := time.Date(2026, 7, 25, 12, 0, 0, 0, loc)
uploaderX := uint64(11)
uploaderY := uint64(12)
listingOne := uint64(101)
listingTwo := uint64(102)
listingThree := uint64(103)
uploads := []model.ListingUpload{
{UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingOne, CreatedAt: now},
{UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingTwo, CreatedAt: now.Add(time.Hour)},
{UploaderName: "小美", MatchedAdminID: &uploaderY, ListingID: &listingThree, CreatedAt: now.Add(2 * time.Hour)},
{UploaderName: "小晴", MatchedAdminID: &uploaderX, ListingID: &listingOne, CreatedAt: now.AddDate(0, 0, -1)},
}
if err := db.Create(&uploads).Error; err != nil {
t.Fatalf("create uploads error = %v", err)
}
overview, err := NewRepository(db).listingDailyOverview(t.Context(), now, 2)
if err != nil {
t.Fatalf("listingDailyOverview() error = %v", err)
}
if len(overview.TodayUploaders) != 2 {
t.Fatalf("today uploaders = %#v, want 2 rows", overview.TodayUploaders)
}
counts := make(map[string]int64)
for _, item := range overview.TodayUploaders {
counts[item.UploaderName] = item.UploadCount
}
if counts["小晴"] != 2 || counts["小美"] != 1 {
t.Fatalf("today uploader counts = %#v, want 小晴=2, 小美=1", counts)
}
if len(overview.UploaderTrend) != 3 {
t.Fatalf("uploader trend = %#v, want 3 rows", overview.UploaderTrend)
}
}
@@ -0,0 +1,23 @@
package admindashboard
import (
"context"
"errors"
)
var ErrDependencyUnavailable = errors.New("dependency unavailable")
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Summary(ctx context.Context) (*DashboardDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.Summary(ctx)
}