From 42b250cd2c534df1f7f0e9df52dfcfc05dd2b720 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 11 Jul 2026 23:37:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E4=B8=8A=E4=B8=8B=E6=9E=B6?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/listingstatus/event.go | 128 ++++++++ backend/internal/listingstatus/event_test.go | 73 +++++ .../internal/model/listing_status_event.go | 22 ++ .../internal/modules/admindashboard/dto.go | 26 +- .../modules/admindashboard/repository.go | 105 ++++++- .../internal/modules/dispute/arbitration.go | 12 + backend/internal/modules/listing/mutation.go | 16 + backend/internal/modules/listing/review.go | 12 + .../internal/modules/order/admin_actions.go | 20 +- backend/internal/modules/order/assets.go | 25 +- .../modules/order/checkout_finalize.go | 4 +- backend/internal/modules/order/lifecycle.go | 8 +- .../internal/modules/order/repository_test.go | 8 +- backend/internal/modules/pickup/repository.go | 13 + .../000025_listing_status_events.sql | 22 ++ .../src/features/admin/api/adminDashboard.ts | 36 ++- .../admin/views/AdminDashboardView.vue | 289 +++++++++++++++++- 17 files changed, 792 insertions(+), 27 deletions(-) create mode 100644 backend/internal/listingstatus/event.go create mode 100644 backend/internal/listingstatus/event_test.go create mode 100644 backend/internal/model/listing_status_event.go create mode 100644 backend/migrations/000025_listing_status_events.sql diff --git a/backend/internal/listingstatus/event.go b/backend/internal/listingstatus/event.go new file mode 100644 index 0000000..34e7b8a --- /dev/null +++ b/backend/internal/listingstatus/event.go @@ -0,0 +1,128 @@ +package listingstatus + +import ( + "strings" + "time" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/timeutil" + + "gorm.io/gorm" +) + +// 统计用的事件类型(与 to_status 对齐时优先用这些标准值)。 +const ( + EventPublished = "published" + EventOffline = "offline" + EventRented = "rented" + EventCompleted = "completed" + EventSealed = "sealed" + EventAbnormal = "abnormal" +) + +// 来源 +const ( + SourceSeller = "seller" + SourceAdmin = "admin" + SourceReview = "review" + SourceOrder = "order" + SourcePickup = "pickup" + SourceDispute = "dispute" + SourceSystem = "system" +) + +// 操作者类型 +const ( + ActorUser = "user" + ActorAdmin = "admin" + ActorSystem = "system" +) + +// Entry 单次状态变更事件。 +type Entry struct { + ListingID uint64 + OwnerID uint64 + EventType string + FromStatus string + ToStatus string + Source string + ActorType string + ActorID uint64 + Remark string + // CreatedAt 为空时使用上海时间当前时刻。 + CreatedAt time.Time +} + +// Append 写入一条状态事件。from==to 或 event 无法识别时跳过。 +func Append(tx *gorm.DB, entry Entry) error { + if tx == nil { + return nil + } + from := strings.TrimSpace(entry.FromStatus) + to := strings.TrimSpace(entry.ToStatus) + if to == "" { + to = strings.TrimSpace(entry.EventType) + } + if to == "" || from == to { + return nil + } + eventType := strings.TrimSpace(entry.EventType) + if eventType == "" { + eventType = normalizeEventType(to) + } + if eventType == "" { + return nil + } + createdAt := entry.CreatedAt + if createdAt.IsZero() { + createdAt = timeutil.ShanghaiNow() + } + row := model.ListingStatusEvent{ + ListingID: entry.ListingID, + OwnerID: entry.OwnerID, + EventType: eventType, + FromStatus: from, + ToStatus: to, + Source: strings.TrimSpace(entry.Source), + ActorType: strings.TrimSpace(entry.ActorType), + ActorID: entry.ActorID, + Remark: strings.TrimSpace(entry.Remark), + CreatedAt: createdAt, + } + return tx.Create(&row).Error +} + +// AppendTransition 根据 listing 变更前后状态写事件。 +func AppendTransition( + tx *gorm.DB, + listing *model.RentalListing, + fromStatus string, + source string, + actorType string, + actorID uint64, + remark string, +) error { + if listing == nil { + return nil + } + return Append(tx, Entry{ + ListingID: listing.ID, + OwnerID: listing.OwnerID, + EventType: normalizeEventType(listing.Status), + FromStatus: fromStatus, + ToStatus: listing.Status, + Source: source, + ActorType: actorType, + ActorID: actorID, + Remark: remark, + }) +} + +func normalizeEventType(status string) string { + switch strings.TrimSpace(status) { + case EventPublished, EventOffline, EventRented, EventCompleted, EventSealed, EventAbnormal: + return status + default: + return "" + } +} diff --git a/backend/internal/listingstatus/event_test.go b/backend/internal/listingstatus/event_test.go new file mode 100644 index 0000000..c9b0f9f --- /dev/null +++ b/backend/internal/listingstatus/event_test.go @@ -0,0 +1,73 @@ +package listingstatus + +import ( + "testing" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.ListingStatusEvent{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +func TestAppendSkipsUnchangedOrUnknown(t *testing.T) { + db := setupTestDB(t) + + if err := Append(db, Entry{ListingID: 1, FromStatus: "published", ToStatus: "published", EventType: EventPublished}); err != nil { + t.Fatalf("same status should skip: %v", err) + } + if err := Append(db, Entry{ListingID: 1, FromStatus: "draft", ToStatus: "draft", EventType: ""}); err != nil { + t.Fatalf("unknown should skip: %v", err) + } + var count int64 + if err := db.Model(&model.ListingStatusEvent{}).Count(&count).Error; err != nil { + t.Fatalf("count: %v", err) + } + if count != 0 { + t.Fatalf("expected 0 events, got %d", count) + } +} + +func TestAppendTransitionWritesPublishedAndOffline(t *testing.T) { + db := setupTestDB(t) + listing := &model.RentalListing{ID: 9, OwnerID: 3, Status: EventPublished} + if err := AppendTransition(db, listing, "draft", SourceSeller, ActorUser, 3, "发布"); err != nil { + t.Fatalf("append published: %v", err) + } + listing.Status = EventOffline + if err := AppendTransition(db, listing, EventPublished, SourceAdmin, ActorAdmin, 1, "后台下架"); err != nil { + t.Fatalf("append offline: %v", err) + } + + var rows []model.ListingStatusEvent + if err := db.Order("id asc").Find(&rows).Error; err != nil { + t.Fatalf("find: %v", err) + } + if len(rows) != 2 { + t.Fatalf("expected 2 events, got %d", len(rows)) + } + if rows[0].EventType != EventPublished || rows[0].Source != SourceSeller { + t.Fatalf("unexpected first event: %#v", rows[0]) + } + if rows[1].EventType != EventOffline || rows[1].Source != SourceAdmin { + t.Fatalf("unexpected second event: %#v", rows[1]) + } + if rows[0].CreatedAt.IsZero() || rows[1].CreatedAt.After(time.Now().Add(time.Minute)) { + t.Fatalf("unexpected timestamps: %v %v", rows[0].CreatedAt, rows[1].CreatedAt) + } +} diff --git a/backend/internal/model/listing_status_event.go b/backend/internal/model/listing_status_event.go new file mode 100644 index 0000000..36e8f84 --- /dev/null +++ b/backend/internal/model/listing_status_event.go @@ -0,0 +1,22 @@ +package model + +import "time" + +// ListingStatusEvent 记录商品上架/主动下架/交易离架等状态变更,供后台日统计使用。 +type ListingStatusEvent struct { + ID uint64 `gorm:"primaryKey" json:"id"` + ListingID uint64 `gorm:"not null;index" json:"listing_id"` + OwnerID uint64 `gorm:"not null;default:0" json:"owner_id"` + EventType string `gorm:"size:32;not null" json:"event_type"` + FromStatus string `gorm:"size:32;not null;default:''" json:"from_status"` + ToStatus string `gorm:"size:32;not null;default:''" json:"to_status"` + Source string `gorm:"size:32;not null;default:''" json:"source"` + ActorType string `gorm:"size:16;not null;default:''" json:"actor_type"` + ActorID uint64 `gorm:"not null;default:0" json:"actor_id"` + Remark string `gorm:"size:255;not null;default:''" json:"remark"` + CreatedAt time.Time `json:"created_at"` +} + +func (ListingStatusEvent) TableName() string { + return "listing_status_events" +} diff --git a/backend/internal/modules/admindashboard/dto.go b/backend/internal/modules/admindashboard/dto.go index 77906a8..c886713 100644 --- a/backend/internal/modules/admindashboard/dto.go +++ b/backend/internal/modules/admindashboard/dto.go @@ -3,11 +3,12 @@ package admindashboard import "time" type DashboardDTO struct { - Metrics MetricsDTO `json:"metrics"` - Pending PendingDTO `json:"pending"` - RecentOrders []RecentOrderDTO `json:"recent_orders"` - RecentDisputes []RecentDisputeDTO `json:"recent_disputes"` - GeneratedAt time.Time `json:"generated_at"` + Metrics MetricsDTO `json:"metrics"` + Pending PendingDTO `json:"pending"` + ListingDaily ListingDailyOverviewDTO `json:"listing_daily"` + RecentOrders []RecentOrderDTO `json:"recent_orders"` + RecentDisputes []RecentDisputeDTO `json:"recent_disputes"` + GeneratedAt time.Time `json:"generated_at"` } type MetricsDTO struct { @@ -21,6 +22,21 @@ type MetricsDTO struct { TodayLedgerAmountCent int64 `json:"today_ledger_amount_cent"` } +// ListingDailyOverviewDTO 商品上下架统计:今日三组 + 近 7 日趋势。 +type ListingDailyOverviewDTO struct { + Today ListingDayStatsDTO `json:"today"` + Trend []ListingDayStatsDTO `json:"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 PendingDTO struct { ListingReviews int64 `json:"listing_reviews"` Disputes int64 `json:"disputes"` diff --git a/backend/internal/modules/admindashboard/repository.go b/backend/internal/modules/admindashboard/repository.go index d0b7711..bdf0930 100644 --- a/backend/internal/modules/admindashboard/repository.go +++ b/backend/internal/modules/admindashboard/repository.go @@ -4,11 +4,15 @@ import ( "context" "time" + "hfb_sys/backend/internal/listingstatus" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/timeutil" "gorm.io/gorm" ) +const listingDailyTrendDays = 7 + type Repository struct { db *gorm.DB } @@ -19,8 +23,9 @@ func NewRepository(db *gorm.DB) *Repository { func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) { db := r.db.WithContext(ctx) - now := time.Now() - today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + loc := timeutil.ShanghaiLocation() + now := timeutil.ShanghaiNow() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) metrics := MetricsDTO{} pending := PendingDTO{} @@ -63,6 +68,11 @@ func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) { 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 + } recentOrders, err := r.recentOrders(ctx) if err != nil { return nil, err @@ -74,12 +84,103 @@ func (r *Repository) Summary(ctx context.Context) (*DashboardDTO, error) { return &DashboardDTO{ Metrics: metrics, Pending: pending, + ListingDaily: listingDaily, 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 + CreatedAt time.Time + } + events := make([]eventRow, 0) + if err := r.db.WithContext(ctx).Model(&model.ListingStatusEvent{}). + Select("event_type, source, created_at"). + Where("created_at >= ? AND created_at < ?", start, end). + Scan(&events).Error; err != nil { + return ListingDailyOverviewDTO{}, err + } + + type bucket struct { + published int64 + activeOffline int64 + tradeLeave int64 + } + byDay := make(map[string]*bucket, days) + for _, ev := range events { + key := ev.CreatedAt.In(loc).Format("2006-01-02") + b := byDay[key] + if b == nil { + b = &bucket{} + byDay[key] = b + } + switch { + case ev.EventType == listingstatus.EventPublished: + b.published++ + case isActiveOfflineEvent(ev.EventType, ev.Source): + b.activeOffline++ + case isTradeLeaveEvent(ev.EventType, ev.Source): + b.tradeLeave++ + } + } + + 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] + } + return ListingDailyOverviewDTO{ + Today: todayStats, + Trend: trend, + Days: days, + Timezone: "Asia/Shanghai", + }, nil +} + +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 + } +} + func (r *Repository) recentOrders(ctx context.Context) ([]RecentOrderDTO, error) { rows := make([]RecentOrderDTO, 0) err := r.db.WithContext(ctx).Table("rental_orders AS o"). diff --git a/backend/internal/modules/dispute/arbitration.go b/backend/internal/modules/dispute/arbitration.go index cd459e3..488e8dc 100644 --- a/backend/internal/modules/dispute/arbitration.go +++ b/backend/internal/modules/dispute/arbitration.go @@ -7,6 +7,7 @@ import ( "log" "time" + "hfb_sys/backend/internal/listingstatus" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/wallet" @@ -76,6 +77,17 @@ func (r *Repository) Arbitrate(ctx context.Context, adminID uint64, id uint64, r listing.PublishedAt = nil account.Status = "offline" } + if err := listingstatus.AppendTransition( + tx, + &listing, + beforeListingStatus, + listingstatus.SourceDispute, + listingstatus.ActorAdmin, + adminID, + "仲裁结案", + ); err != nil { + return err + } if err := wallet.AppendEntries(tx, settlement.Entries...); err != nil { return err } diff --git a/backend/internal/modules/listing/mutation.go b/backend/internal/modules/listing/mutation.go index 3157526..da9c65b 100644 --- a/backend/internal/modules/listing/mutation.go +++ b/backend/internal/modules/listing/mutation.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "hfb_sys/backend/internal/listingstatus" "hfb_sys/backend/internal/model" "gorm.io/datatypes" @@ -64,6 +65,9 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque if err := tx.Create(&listing).Error; err != nil { return err } + if err := listingstatus.AppendTransition(tx, &listing, "", listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "发布上架"); err != nil { + return err + } // 发布提交时建发布群 var conversationID uint64 @@ -235,6 +239,7 @@ func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint6 listing.PriceCent = normalizedListingPriceCent(req) listing.DepositAmountCent = req.DepositAmountCent + fromStatus := listing.Status listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired) listing.Status = listingStatus listing.ReviewStatus = reviewStatus @@ -247,6 +252,9 @@ func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint6 if err := tx.Save(listing).Error; err != nil { return err } + if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "编辑后上架"); err != nil { + return err + } dto = toDTO(*account, *listing) return nil }) @@ -263,6 +271,7 @@ func (r *Repository) SubmitReview(ctx context.Context, ownerID uint64, listingID if listingLockedForOwnerMutation(listing) { return ErrListingLocked } + fromStatus := listing.Status listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired) listing.Status = listingStatus listing.ReviewStatus = reviewStatus @@ -275,6 +284,9 @@ func (r *Repository) SubmitReview(ctx context.Context, ownerID uint64, listingID if err := tx.Save(listing).Error; err != nil { return err } + if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "提交发布"); err != nil { + return err + } dto = toDTO(*account, *listing) return nil }) @@ -353,6 +365,7 @@ func (r *Repository) Offline(ctx context.Context, ownerID uint64, listingID uint if listingLockedForOwnerMutation(listing) { return ErrListingLocked } + fromStatus := listing.Status listing.Status = "offline" listing.ReviewStatus = "none" listing.ReviewReason = "号主已手动下架" @@ -364,6 +377,9 @@ func (r *Repository) Offline(ctx context.Context, ownerID uint64, listingID uint if err := tx.Save(listing).Error; err != nil { return err } + if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "号主手动下架"); err != nil { + return err + } dto = toDTO(*account, *listing) return nil }) diff --git a/backend/internal/modules/listing/review.go b/backend/internal/modules/listing/review.go index ebb528c..c844cec 100644 --- a/backend/internal/modules/listing/review.go +++ b/backend/internal/modules/listing/review.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "hfb_sys/backend/internal/listingstatus" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" @@ -174,6 +175,13 @@ func (r *Repository) adminUpdateStatus(ctx context.Context, adminID uint64, list if err := tx.Save(listing).Error; err != nil { return err } + source := listingstatus.SourceAdmin + if listingStatus == "published" { + source = listingstatus.SourceReview + } + if err := listingstatus.AppendTransition(tx, listing, beforeListingStatus, source, listingstatus.ActorAdmin, adminID, req.Reason); err != nil { + return err + } if err := notification.Append(tx, notification.Entry{ UserID: listing.OwnerID, Type: "listing_admin", @@ -214,6 +222,7 @@ func (r *Repository) Approve(ctx context.Context, listingID uint64) (*ListingDTO if listing.Status == "rented" || listing.InTransaction { return ErrListingLocked } + fromStatus := listing.Status now := time.Now() listing.Status = "published" listing.ReviewStatus = "approved" @@ -226,6 +235,9 @@ func (r *Repository) Approve(ctx context.Context, listingID uint64) (*ListingDTO if err := tx.Save(listing).Error; err != nil { return err } + if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceReview, listingstatus.ActorAdmin, 0, "审核通过上架"); err != nil { + return err + } listingID := listing.ID if err := notification.Append(tx, notification.Entry{ UserID: listing.OwnerID, diff --git a/backend/internal/modules/order/admin_actions.go b/backend/internal/modules/order/admin_actions.go index 8ad2289..a7a6ccf 100644 --- a/backend/internal/modules/order/admin_actions.go +++ b/backend/internal/modules/order/admin_actions.go @@ -35,7 +35,9 @@ func (r *Repository) AdminClose(ctx context.Context, adminID uint64, orderID uin order.HandoffStatus = handoffStatusAdminClosed order.SettlementStatus = settlementStatusClosed order.SettledAt = &now - archiveAssets(listing, account) + if err := archiveAssets(tx, listing, account); err != nil { + return err + } if beforeOrderStatus != orderStatusPendingPayment { totalCent := order.RentAmountCent + order.DepositAmountCent // 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。 @@ -118,7 +120,9 @@ func (r *Repository) AdminMarkAbnormal(ctx context.Context, adminID uint64, orde beforeAccountStatus := account.Status order.Status = orderStatusAbnormal order.HandoffStatus = handoffStatusAdminAbnormal - markAssetsAbnormal(listing, account) + if err := markAssetsAbnormal(tx, listing, account); err != nil { + return err + } if err := notification.Append(tx, notification.Entry{ UserID: order.RenterID, @@ -450,7 +454,9 @@ func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64, acti order.SettlementStatus = settlementStatusUnsettled order.SettledAt = nil reserveListingForOrder(listing) - markAssetsRented(listing, account) + if err := markAssetsRented(tx, listing, account); err != nil { + return err + } case "close": now := time.Now() @@ -458,7 +464,9 @@ func (r *Repository) AdminRejectRefund(ctx context.Context, orderID uint64, acti order.HandoffStatus = handoffStatusAdminClosed order.SettlementStatus = settlementStatusClosed order.SettledAt = &now - archiveAssets(listing, account) + if err := archiveAssets(tx, listing, account); err != nil { + return err + } default: return errors.New("无效的驳回操作,可选 restore 或 close") @@ -500,7 +508,9 @@ func (r *Repository) AdminSeal(ctx context.Context, adminID uint64, orderID uint order.HandoffStatus = handoffStatusAdminClosed order.SettlementStatus = settlementStatusClosed order.SettledAt = &now - sealAssets(listing, account) + if err := sealAssets(tx, listing, account); err != nil { + return err + } if beforeOrderStatus != orderStatusPendingPayment { totalCent := order.RentAmountCent + order.DepositAmountCent // 押金暂扣:若订单已暂扣,拦截退款中的押金部分挂起不退,其余照常原路退。 diff --git a/backend/internal/modules/order/assets.go b/backend/internal/modules/order/assets.go index 1e112a8..1a5f611 100644 --- a/backend/internal/modules/order/assets.go +++ b/backend/internal/modules/order/assets.go @@ -1,6 +1,7 @@ package order import ( + "hfb_sys/backend/internal/listingstatus" "hfb_sys/backend/internal/model" "gorm.io/gorm" @@ -45,41 +46,53 @@ func reserveListingForOrder(listing *model.RentalListing) { listing.InTransaction = true } -func markAssetsRented(listing *model.RentalListing, account *model.GameAccount) { +func markAssetsRented(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error { + from := listing.Status listing.Status = listingStatusRented account.Status = accountStatusRented + return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "支付成功租出") } -func releaseAssetsForRental(listing *model.RentalListing, account *model.GameAccount) { +func releaseAssetsForRental(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error { + from := listing.Status listing.Status = listingStatusPublished listing.InTransaction = false account.Status = accountStatusPublished + return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "订单取消恢复上架") } -func archiveAssets(listing *model.RentalListing, account *model.GameAccount) { +func archiveAssets(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error { + from := listing.Status listing.Status = listingStatusOffline listing.InTransaction = false listing.PublishedAt = nil account.Status = accountStatusOffline + return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "订单归档下架") } -func sealAssets(listing *model.RentalListing, account *model.GameAccount) { +func sealAssets(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error { + from := listing.Status listing.Status = listingStatusSealed listing.InTransaction = false listing.PublishedAt = nil account.Status = accountStatusSealed + return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "订单封存") } -func completeAssets(listing *model.RentalListing, account *model.GameAccount) { +func completeAssets(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error { + from := listing.Status listing.Status = listingStatusCompleted listing.InTransaction = false listing.PublishedAt = nil account.Status = accountStatusOffline + return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "订单完成售出") } -func markAssetsAbnormal(listing *model.RentalListing, account *model.GameAccount) { +func markAssetsAbnormal(tx *gorm.DB, listing *model.RentalListing, account *model.GameAccount) error { + from := listing.Status listing.Status = listingStatusAbnormal listing.InTransaction = false listing.PublishedAt = nil account.Status = accountStatusAbnormal + return listingstatus.AppendTransition(tx, listing, from, listingstatus.SourceOrder, listingstatus.ActorSystem, 0, "标记异常") } diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index cd53602..cea4aa9 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -21,7 +21,9 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che order.SettlementStatus = settlementStatusSettled order.SettledAt = &now order.OwnerSettledAt = &now - completeAssets(listing, account) + if err := completeAssets(tx, listing, account); err != nil { + return nil, err + } settlement := buildCheckoutSettlement(*order, checkout) if err := appendCheckoutOwnerIncome(tx, order, settlement); err != nil { diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index fd2bde5..ab0b2d3 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -181,7 +181,9 @@ func (r *Repository) ConfirmPaidFromChannelTx(tx *gorm.DB, orderID uint64) (uint order.Status = orderStatusPendingHandoff order.HandoffStatus = handoffStatusPendingOwner order.HandoffStartedAt = &now - markAssetsRented(listing, account) + if err := markAssetsRented(tx, listing, account); err != nil { + return 0, err + } // 拉租客进发布群(替代原来的建订单群) conversationID := uint64(0) @@ -275,7 +277,9 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) ); err != nil { return err } - releaseAssetsForRental(listing, account) + if err := releaseAssetsForRental(tx, listing, account); err != nil { + return err + } if err := closePendingOrderPayments(tx, order.ID, "order_cancel"); err != nil { return err } diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go index c7e4322..5a44568 100644 --- a/backend/internal/modules/order/repository_test.go +++ b/backend/internal/modules/order/repository_test.go @@ -149,7 +149,9 @@ func TestArchiveAssetsMovesListingOffline(t *testing.T) { } account := model.GameAccount{Status: accountStatusRented} - archiveAssets(&listing, &account) + if err := archiveAssets(nil, &listing, &account); err != nil { + t.Fatalf("archiveAssets: %v", err) + } if listing.Status != listingStatusOffline { t.Fatalf("listing.Status = %q, want %q", listing.Status, listingStatusOffline) @@ -174,7 +176,9 @@ func TestCompleteAssetsMovesListingCompleted(t *testing.T) { } account := model.GameAccount{Status: accountStatusRented} - completeAssets(&listing, &account) + if err := completeAssets(nil, &listing, &account); err != nil { + t.Fatalf("completeAssets: %v", err) + } if listing.Status != listingStatusCompleted { t.Fatalf("listing.Status = %q, want %q", listing.Status, listingStatusCompleted) diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index 7670771..940508b 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -12,6 +12,7 @@ import ( "time" "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/listingstatus" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/wallet" @@ -178,11 +179,23 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete } // listing 置 completed:listingLockedForOwnerMutation 已拦截此状态再上架。 + fromStatus := listing.Status listing.InTransaction = true listing.Status = "completed" if err := tx.Save(&listing).Error; err != nil { return err } + if err := listingstatus.AppendTransition( + tx, + &listing, + fromStatus, + listingstatus.SourcePickup, + listingstatus.ActorAdmin, + 0, + "管理员提号完成", + ); err != nil { + return err + } if err := wallet.AppendEntries(tx, wallet.Entry{ UserID: pickup.OwnerID, diff --git a/backend/migrations/000025_listing_status_events.sql b/backend/migrations/000025_listing_status_events.sql new file mode 100644 index 0000000..2071433 --- /dev/null +++ b/backend/migrations/000025_listing_status_events.sql @@ -0,0 +1,22 @@ +-- +goose Up + +CREATE TABLE IF NOT EXISTS listing_status_events ( + id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, + listing_id BIGINT UNSIGNED NOT NULL COMMENT '商品ID', + owner_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '号主ID', + event_type VARCHAR(32) NOT NULL COMMENT '事件类型: published/offline/rented/completed/sealed/abnormal', + from_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '变更前状态', + to_status VARCHAR(32) NOT NULL DEFAULT '' COMMENT '变更后状态', + source VARCHAR(32) NOT NULL DEFAULT '' COMMENT '来源: seller/admin/review/order/pickup/dispute/system', + actor_type VARCHAR(16) NOT NULL DEFAULT '' COMMENT '操作者类型: user/admin/system', + actor_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作者ID', + remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件时间', + KEY idx_listing_status_events_day_type (created_at, event_type), + KEY idx_listing_status_events_type_source_day (event_type, source, created_at), + KEY idx_listing_status_events_listing (listing_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品上架/下架/交易离架状态事件'; + +-- +goose Down + +DROP TABLE IF EXISTS listing_status_events; diff --git a/frontend/src/features/admin/api/adminDashboard.ts b/frontend/src/features/admin/api/adminDashboard.ts index b183f09..9cda77b 100644 --- a/frontend/src/features/admin/api/adminDashboard.ts +++ b/frontend/src/features/admin/api/adminDashboard.ts @@ -20,6 +20,20 @@ export interface DashboardPending { pending_return_confirms: number } +export interface ListingDayStats { + date: string + published_count: number + active_offline_count: number + trade_leave_count: number +} + +export interface ListingDailyOverview { + today: ListingDayStats + trend: ListingDayStats[] + days: number + timezone: string +} + export interface DashboardRecentOrder { id: number order_no: string @@ -46,16 +60,32 @@ export interface DashboardRecentDispute { export interface AdminDashboard { metrics: DashboardMetrics pending: DashboardPending + listing_daily: ListingDailyOverview recent_orders: DashboardRecentOrder[] recent_disputes: DashboardRecentDispute[] generated_at: string } +const emptyDayStats = (): ListingDayStats => ({ + date: '', + published_count: 0, + active_offline_count: 0, + trade_leave_count: 0, +}) + export async function fetchAdminDashboard() { const { data } = await apiClient.get>('/admin/dashboard') + const payload = data.data + const listingDaily = payload.listing_daily return { - ...data.data, - recent_orders: data.data.recent_orders ?? [], - recent_disputes: data.data.recent_disputes ?? [], + ...payload, + listing_daily: { + today: listingDaily?.today ?? emptyDayStats(), + trend: listingDaily?.trend ?? [], + days: listingDaily?.days ?? 7, + timezone: listingDaily?.timezone ?? 'Asia/Shanghai', + }, + recent_orders: payload.recent_orders ?? [], + recent_disputes: payload.recent_disputes ?? [], } } diff --git a/frontend/src/features/admin/views/AdminDashboardView.vue b/frontend/src/features/admin/views/AdminDashboardView.vue index ad3e67b..c54ef6a 100644 --- a/frontend/src/features/admin/views/AdminDashboardView.vue +++ b/frontend/src/features/admin/views/AdminDashboardView.vue @@ -1,6 +1,8 @@