From c87883cc65b59a3020309a2a518f2d83ada781ff Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 28 Aug 2026 23:48:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=AE=A2=E5=8D=95=E4=BA=A4?= =?UTF-8?q?=E6=8E=A5=E7=BB=93=E8=B4=A6=E6=B5=81=E7=A8=8B=E7=95=99=E7=97=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/database/test_helper.go | 3 + backend/internal/model/order.go | 2 + backend/internal/model/process_event.go | 34 +++ backend/internal/modules/order/checkout.go | 27 ++- .../internal/modules/order/deposit_hold.go | 9 + backend/internal/modules/order/dto.go | 24 ++ .../internal/modules/order/force_handoff.go | 5 + .../internal/modules/order/handler_admin.go | 13 + backend/internal/modules/order/handoff.go | 5 + backend/internal/modules/order/lifecycle.go | 23 ++ .../modules/order/platform_managed.go | 21 ++ backend/internal/modules/order/presenter.go | 12 + .../internal/modules/order/process_events.go | 64 +++++ backend/internal/modules/order/service.go | 7 + backend/internal/modules/pickup/handler.go | 13 + .../internal/modules/pickup/process_events.go | 76 ++++++ backend/internal/modules/pickup/repository.go | 49 ++++ .../modules/pickup/repository_test.go | 2 + backend/internal/modules/pickup/service.go | 7 + backend/internal/processlog/processlog.go | 128 ++++++++++ backend/internal/router/router.go | 2 + ...000058_process_events_and_order_source.sql | 37 +++ .../src/features/admin/api/adminPickup.ts | 26 ++ .../admin/components/ProcessTimeline.vue | 225 ++++++++++++++++++ .../admin/views/AdminOrderDetailView.vue | 57 ++++- .../admin/views/AdminPickupDetailView.vue | 19 +- frontend/src/features/orders/api/orders.ts | 30 +++ 27 files changed, 915 insertions(+), 5 deletions(-) create mode 100644 backend/internal/model/process_event.go create mode 100644 backend/internal/modules/order/process_events.go create mode 100644 backend/internal/modules/pickup/process_events.go create mode 100644 backend/internal/processlog/processlog.go create mode 100644 backend/migrations/000058_process_events_and_order_source.sql create mode 100644 frontend/src/features/admin/components/ProcessTimeline.vue diff --git a/backend/internal/database/test_helper.go b/backend/internal/database/test_helper.go index 11064fd..b1c7a2a 100644 --- a/backend/internal/database/test_helper.go +++ b/backend/internal/database/test_helper.go @@ -38,8 +38,10 @@ func NewTestDBWithName(name string) *gorm.DB { func MigrateListingLifecycleTestSchema(db *gorm.DB) error { return db.AutoMigrate( &model.User{}, + &model.AdminUser{}, &model.GameAccount{}, &model.RentalListing{}, + &model.ListingUpload{}, &model.ListingStatusEvent{}, ) } @@ -61,6 +63,7 @@ func MigrateRentalTransactionTestSchema(db *gorm.DB) error { &model.WalletLedger{}, &model.RenterGrowthLedger{}, &model.AuditLog{}, + &model.ProcessEvent{}, &model.ChatConversation{}, &model.ChatParticipant{}, &model.ChatAdminConversationState{}, diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go index a49ec76..272cdfb 100644 --- a/backend/internal/model/order.go +++ b/backend/internal/model/order.go @@ -41,6 +41,8 @@ type RentalOrder struct { GrowthPointsAwarded int64 `gorm:"not null;default:0" json:"growth_points_awarded"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at"` AccountSnapshot datatypes.JSON `json:"account_snapshot"` + AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"` + SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"` Status string `gorm:"size:32;not null;default:'pending_payment'" json:"status"` HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"` HandoffMode string `gorm:"size:16;not null;default:'owner';index" json:"handoff_mode"` diff --git a/backend/internal/model/process_event.go b/backend/internal/model/process_event.go new file mode 100644 index 0000000..b9bb400 --- /dev/null +++ b/backend/internal/model/process_event.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +// ProcessEvent 是订单、提号等交易流程的不可变操作留痕。 +// 当前业务表仍保存当前状态;此表只追加,用于还原每一步由谁填写、修改和确认。 +type ProcessEvent struct { + ID uint64 `gorm:"primaryKey" json:"id"` + BusinessType string `gorm:"size:32;not null;index:idx_process_event_business" json:"business_type"` + BusinessID uint64 `gorm:"not null;index:idx_process_event_business" json:"business_id"` + Stage string `gorm:"size:32;not null;default:''" json:"stage"` + Action string `gorm:"size:64;not null" json:"action"` + ActorType string `gorm:"size:16;not null" json:"actor_type"` + ActorID uint64 `gorm:"not null;default:0" json:"actor_id"` + ActorName string `gorm:"size:128;not null;default:''" json:"actor_name"` + TargetType string `gorm:"size:16;not null;default:''" json:"target_type"` + TargetID *uint64 `json:"target_id,omitempty"` + TargetName string `gorm:"size:128;not null;default:''" json:"target_name"` + Content string `gorm:"type:text" json:"content"` + Reason string `gorm:"type:text" json:"reason"` + Payload datatypes.JSON `gorm:"not null" json:"payload"` + AttachmentURLs datatypes.JSON `gorm:"column:attachment_urls;not null" json:"attachment_urls"` + StateBefore datatypes.JSON `gorm:"column:state_before;not null" json:"state_before"` + StateAfter datatypes.JSON `gorm:"column:state_after;not null" json:"state_after"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + +func (ProcessEvent) TableName() string { + return "process_events" +} diff --git a/backend/internal/modules/order/checkout.go b/backend/internal/modules/order/checkout.go index 52abe10..acbd364 100644 --- a/backend/internal/modules/order/checkout.go +++ b/backend/internal/modules/order/checkout.go @@ -6,6 +6,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/processlog" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -31,9 +32,12 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID if hasOpen { return ErrCheckoutCannotSubmit } + before := orderState(order) checkoutToUserID := order.OwnerID + checkoutTargetType := processlog.ActorUser if isPlatformSettlementOrder(order) && platformManagedAdminID(order) > 0 { checkoutToUserID = platformManagedAdminID(order) + checkoutTargetType = processlog.ActorAdmin } record := model.HandoffRecord{ OrderID: order.ID, @@ -61,6 +65,9 @@ func (r *Repository) SubmitCheckout(ctx context.Context, userID uint64, orderID order.SettlementStatus = settlementStatusPending now := time.Now() order.HandoffStartedAt = &now + if err := appendOrderEvent(tx, order, "checkout", "checkout_submitted", processlog.ActorUser, userID, checkoutTargetType, &checkoutToUserID, req.Content, "", checkoutEventPayload(checkout), before, req.EvidenceURLS); err != nil { + return err + } orderID := order.ID if isPlatformSettlementOrder(order) { if err := appendManagedAdminNotification(tx, order, "checkout", "代管订单待确认结账", "租客已发起结账,请检查账号状态和消耗明细后确认。"); err != nil { @@ -108,6 +115,7 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID if order.Status != orderStatusPendingCheckoutConfirm || order.HandoffStatus != handoffStatusPendingOwnerCheckout { return ErrCheckoutCannotConfirm } + before := orderState(order) checkout, err := lockOpenCheckout(tx, order.ID) if err != nil { return err @@ -128,7 +136,10 @@ func (r *Repository) ConfirmCheckout(ctx context.Context, userID uint64, orderID checkout.OwnerAdjustedAt = &now action, err := r.finalizeCheckout(tx, &order, checkout, "号主已确认结账,订单完成。") refund = action - return err + if err != nil { + return err + } + return appendOrderEvent(tx, order, "checkout", "checkout_confirmed", processlog.ActorUser, userID, processlog.ActorUser, &order.RenterID, "号主确认当前结账方案,订单完成。", "", checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS)) }) if err != nil { return err @@ -154,6 +165,7 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID if order.Status != orderStatusPendingCheckoutConfirm && order.Status != orderStatusPendingCheckoutAccept { return ErrCheckoutCannotCounter } + before := orderState(order) checkout, err := lockOpenCheckout(tx, order.ID) if err != nil { return err @@ -221,6 +233,13 @@ func (r *Repository) CounterCheckout(ctx context.Context, userID uint64, orderID if err := tx.Save(&order).Error; err != nil { return err } + targetID := order.OwnerID + if isOwner { + targetID = order.RenterID + } + if err := appendOrderEvent(tx, order, "checkout", "checkout_countered", processlog.ActorUser, userID, processlog.ActorUser, &targetID, "", req.Reason, checkoutEventPayload(*checkout), before, req.EvidenceURLS); err != nil { + return err + } if err := tx.Save(checkout).Error; err != nil { return err } @@ -285,6 +304,7 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID if order.Status != orderStatusPendingCheckoutAccept || order.HandoffStatus != handoffStatusPendingRenterCheckout { return ErrCheckoutCannotConfirm } + before := orderState(order) checkout, err := lockOpenCheckout(tx, order.ID) if err != nil { return err @@ -300,7 +320,10 @@ func (r *Repository) AcceptCheckout(ctx context.Context, userID uint64, orderID checkout.RenterConfirmedAt = &now action, err := r.finalizeCheckout(tx, &order, checkout, "租客已确认结账协商,订单完成。") refund = action - return err + if err != nil { + return err + } + return appendOrderEvent(tx, order, "checkout", "checkout_accepted", processlog.ActorUser, userID, processlog.ActorUser, &order.OwnerID, "租客接受当前结账方案,订单完成。", "", checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS)) }) if err != nil { return err diff --git a/backend/internal/modules/order/deposit_hold.go b/backend/internal/modules/order/deposit_hold.go index 0cb9588..75c3e46 100644 --- a/backend/internal/modules/order/deposit_hold.go +++ b/backend/internal/modules/order/deposit_hold.go @@ -5,6 +5,7 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/processlog" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -23,6 +24,7 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order if !canHoldDeposit(order) { return ErrDepositCannotHold } + before := orderState(order) beforeStatus := order.DepositHoldStatus now := time.Now() order.DepositHoldStatus = depositHoldStatusHeld @@ -30,6 +32,9 @@ func (r *Repository) AdminHoldDeposit(ctx context.Context, adminID uint64, order order.DepositHeldBy = &adminID order.DepositHeldAt = &now order.DepositHoldReleasedAt = nil + if err := appendOrderEvent(tx, order, "settlement", "deposit_held", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服暂扣订单押金。", req.Reason, map[string]any{"deposit_amount_cent": order.DepositAmountCent}, before, nil); err != nil { + return err + } if err := appendAuditLog(tx, adminID, "order.deposit_hold", "order", order.ID, meta, map[string]any{ "order_id": order.ID, "order_no": order.OrderNo, @@ -61,9 +66,13 @@ func (r *Repository) AdminReleaseDeposit(ctx context.Context, adminID uint64, or if holdAmountCent <= 0 { return ErrDepositHoldAmountEmpty } + before := orderState(order) now := time.Now() order.DepositHoldStatus = depositHoldStatusReleased order.DepositHoldReleasedAt = &now + if err := appendOrderEvent(tx, order, "settlement", "deposit_released", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服归还已暂扣的押金。", req.Reason, map[string]any{"deposit_hold_amount_cent": holdAmountCent}, before, nil); err != nil { + return err + } action, err := r.prepareRefund(&order, holdAmountCent, refundBizDeposit, "暂扣押金归还原路退回") if err != nil { return err diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 344ecd3..7848044 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -51,6 +51,8 @@ type OrderDTO struct { GrowthPointsAwarded int64 `json:"growth_points_awarded,omitempty"` GrowthPointsAwardedAt *time.Time `json:"growth_points_awarded_at,omitempty"` AccountSnapshot datatypes.JSON `json:"account_snapshot"` + AccountSource string `json:"account_source"` + SourceChannel string `json:"source_channel,omitempty"` Status string `json:"status"` HandoffStatus string `json:"handoff_status"` HandoffMode string `json:"handoff_mode"` @@ -190,6 +192,28 @@ type HandoffRecordDTO struct { CreatedAt time.Time `json:"created_at"` } +// ProcessEventDTO 是后台交易时间线中的一条不可变过程记录。 +type ProcessEventDTO struct { + ID uint64 `json:"id"` + BusinessType string `json:"business_type"` + BusinessID uint64 `json:"business_id"` + Stage string `json:"stage"` + Action string `json:"action"` + ActorType string `json:"actor_type"` + ActorID uint64 `json:"actor_id"` + ActorName string `json:"actor_name"` + TargetType string `json:"target_type"` + TargetID *uint64 `json:"target_id,omitempty"` + TargetName string `json:"target_name"` + Content string `json:"content"` + Reason string `json:"reason"` + Payload datatypes.JSON `json:"payload"` + AttachmentURLs []string `json:"attachment_urls"` + StateBefore datatypes.JSON `json:"state_before"` + StateAfter datatypes.JSON `json:"state_after"` + CreatedAt time.Time `json:"created_at"` +} + type CheckoutDTO struct { ID uint64 `json:"id"` OrderID uint64 `json:"order_id"` diff --git a/backend/internal/modules/order/force_handoff.go b/backend/internal/modules/order/force_handoff.go index 9cd1d7d..0c89ddf 100644 --- a/backend/internal/modules/order/force_handoff.go +++ b/backend/internal/modules/order/force_handoff.go @@ -7,6 +7,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/processlog" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -51,6 +52,7 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde if !canAdminForceHandoff(order) || strings.TrimSpace(req.Reason) == "" { return ErrOrderCannotForceHandoff } + before := orderState(order) if order.RefundStatus == refundStatusPending || order.RefundStatus == refundStatusPendingReview { return ErrOrderCannotForceHandoff } @@ -100,6 +102,9 @@ func (r *Repository) AdminForceHandoff(ctx context.Context, adminID uint64, orde order.HandoffStatus = handoffStatusReceived order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot) order.RentedAt = &now + if err := appendOrderEvent(tx, order, "handoff", "admin_force_handoff", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, content, req.Reason, nil, before, nil); err != nil { + return err + } orderID := order.ID if err := notification.Append(tx, notification.Entry{ diff --git a/backend/internal/modules/order/handler_admin.go b/backend/internal/modules/order/handler_admin.go index 6d8416f..3a677f7 100644 --- a/backend/internal/modules/order/handler_admin.go +++ b/backend/internal/modules/order/handler_admin.go @@ -60,6 +60,19 @@ func (h *Handler) AdminHandoffRecords(c *gin.Context) { response.OK(c, gin.H{"items": items}) } +func (h *Handler) AdminProcessEvents(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + items, err := h.service.ProcessEventsAdmin(c.Request.Context(), id) + if err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + func (h *Handler) AdminClose(c *gin.Context) { h.adminAction(c, h.service.AdminClose, gin.H{"closed": true}) } diff --git a/backend/internal/modules/order/handoff.go b/backend/internal/modules/order/handoff.go index f9c7b39..361a31d 100644 --- a/backend/internal/modules/order/handoff.go +++ b/backend/internal/modules/order/handoff.go @@ -5,6 +5,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/processlog" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -20,6 +21,7 @@ func (r *Repository) SubmitHandoff(ctx context.Context, userID uint64, orderID u if order.OwnerID != userID { return ErrPermissionDenied } + before := orderState(order) // 号主交接超时(owner_timeout)后仍允许补提交,避免临时延误导致订单卡死。 if order.Status != orderStatusPendingHandoff || (order.HandoffStatus != handoffStatusPendingOwner && order.HandoffStatus != handoffStatusOwnerTimeout) { @@ -37,6 +39,9 @@ func (r *Repository) SubmitHandoff(ctx context.Context, userID uint64, orderID u return err } order.HandoffStatus = handoffStatusPendingRenterConfirm + if err := appendOrderEvent(tx, order, "handoff", "owner_handoff_submitted", processlog.ActorUser, userID, processlog.ActorUser, &order.RenterID, req.Content, "", nil, before, nil); err != nil { + return err + } orderID := order.ID renterContent := "请查看交接记录,确认账号可正常登录后点击确认收号。" if lateSubmit { diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index 530e6ef..f6e303c 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -9,6 +9,7 @@ import ( "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/rentergrowth" + "hfb_sys/backend/internal/processlog" "gorm.io/datatypes" "gorm.io/gorm" @@ -44,6 +45,10 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ if err != nil { return err } + accountSource, sourceChannel, err := orderAccountSourceSnapshot(tx, listing.ID) + if err != nil { + return err + } orderNo, err := newOrderNo() if err != nil { return err @@ -89,6 +94,8 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ RenterGrowthLevelName: growthSnapshot.LevelName, RenterDiscountBps: growthSnapshot.DiscountBps, AccountSnapshot: snapshot, + AccountSource: accountSource, + SourceChannel: sourceChannel, Status: orderStatusPendingPayment, HandoffStatus: handoffStatusNone, HandoffMode: listingHandoffMode(listing), @@ -126,6 +133,18 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ return r.FindForUser(ctx, renterID, createdID) } +func orderAccountSourceSnapshot(tx *gorm.DB, listingID uint64) (string, string, error) { + var upload model.ListingUpload + err := tx.Where("listing_id = ?", listingID).Order("id DESC").First(&upload).Error + if err == gorm.ErrRecordNotFound { + return "internal", "", nil + } + if err != nil { + return "", "", err + } + return "external", upload.SourceChannel, nil +} + func listingHandoffMode(listing model.RentalListing) string { if listing.HandoffMode != "" { return listing.HandoffMode @@ -421,6 +440,7 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID if order.Status != orderStatusPendingHandoff || order.HandoffStatus != handoffStatusPendingRenterConfirm { return ErrOrderCannotReceive } + before := orderState(order) now := time.Now() if err := tx.Model(&model.HandoffRecord{}). Where("order_id = ? AND type IN ?", order.ID, []string{"owner_handoff", "platform_handoff"}). @@ -431,6 +451,9 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID order.HandoffStatus = handoffStatusReceived order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot) order.RentedAt = &now + if err := appendOrderEvent(tx, order, "handoff", "renter_received_confirmed", processlog.ActorUser, userID, processlog.ActorUser, &order.OwnerID, "租客确认已收号,订单开始租用。", "", nil, before, nil); err != nil { + return err + } orderID := order.ID if isPlatformHandoffOrder(order) { if err := appendManagedAdminNotification(tx, order, "handoff", "租客已确认收号", "代管订单已进入使用中。"); err != nil { diff --git a/backend/internal/modules/order/platform_managed.go b/backend/internal/modules/order/platform_managed.go index 2c0b07b..5d478de 100644 --- a/backend/internal/modules/order/platform_managed.go +++ b/backend/internal/modules/order/platform_managed.go @@ -7,6 +7,7 @@ import ( "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/adminnotification" "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/processlog" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -81,6 +82,7 @@ func (r *Repository) AdminPlatformHandoff(ctx context.Context, adminID uint64, o if !canAdminPlatformHandoff(order) { return ErrOrderCannotHandoff } + before := orderState(order) beforeHandoffStatus := order.HandoffStatus if order.ManagedAdminID == nil { order.ManagedAdminID = &adminID @@ -98,6 +100,9 @@ func (r *Repository) AdminPlatformHandoff(ctx context.Context, adminID uint64, o now := time.Now() order.HandoffStatus = handoffStatusPendingRenterConfirm order.HandoffStartedAt = &now + if err := appendOrderEvent(tx, order, "handoff", "platform_handoff_submitted", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, req.Content, req.Reason, nil, before, nil); err != nil { + return err + } orderID := order.ID content := "客服已提交交接说明,请查看交接记录,确认账号可正常登录后点击确认收号。" if beforeHandoffStatus == handoffStatusOwnerTimeout { @@ -146,6 +151,7 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u if !canAdminPlatformCheckoutConfirm(order) { return ErrCheckoutCannotConfirm } + before := orderState(order) checkout, err := lockOpenCheckout(tx, order.ID) if err != nil { return err @@ -175,6 +181,9 @@ func (r *Repository) AdminPlatformCheckoutConfirm(ctx context.Context, adminID u if err != nil { return err } + if err := appendOrderEvent(tx, order, "checkout", "platform_checkout_confirmed", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服确认当前结账方案,订单完成。", req.Reason, checkoutEventPayload(*checkout), before, decodeStringList(checkout.EvidenceURLS)); err != nil { + return err + } return appendAuditLog(tx, adminID, "order.platform_checkout_confirm", "order", order.ID, meta, map[string]any{ "order_id": order.ID, "order_no": order.OrderNo, @@ -207,6 +216,7 @@ func (r *Repository) AdminPlatformCheckoutCounter(ctx context.Context, adminID u if !canAdminPlatformCheckoutConfirm(order) { return ErrCheckoutCannotCounter } + before := orderState(order) if order.ManagedAdminID == nil { order.ManagedAdminID = &adminID } @@ -259,6 +269,9 @@ func (r *Repository) AdminPlatformCheckoutCounter(ctx context.Context, adminID u if err := tx.Create(&record).Error; err != nil { return err } + if err := appendOrderEvent(tx, order, "checkout", "platform_checkout_countered", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "", req.Reason, checkoutEventPayload(*checkout), before, req.EvidenceURLS); err != nil { + return err + } if err := appendAuditLog(tx, adminID, "order.platform_checkout_counter", "order", order.ID, meta, map[string]any{ "order_id": order.ID, "order_no": order.OrderNo, @@ -308,6 +321,7 @@ func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID u if !canAdminPlatformCheckoutConfirm(order) { return ErrCheckoutCannotCounter } + before := orderState(order) if order.ManagedAdminID == nil { order.ManagedAdminID = &adminID } @@ -369,6 +383,9 @@ func (r *Repository) AdminPlatformCheckoutDispute(ctx context.Context, adminID u if err := tx.Create(&record).Error; err != nil { return err } + if err := appendOrderEvent(tx, order, "dispute", "platform_checkout_dispute_opened", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.RenterID, "客服发起结账争议。", req.Reason, map[string]any{"checkout_id": checkout.ID, "dispute_id": row.ID}, before, nil); err != nil { + return err + } disputeID := row.ID orderID := order.ID if err := notification.Append(tx, notification.Entry{ @@ -415,6 +432,7 @@ func (r *Repository) AdminMarkOfflineSettlement(ctx context.Context, adminID uin if !canAdminMarkOfflineSettlement(order) { return ErrOfflineSettlementCannotMark } + before := orderState(order) beforeStatus := order.OfflineSettlementStatus now := time.Now() order.OfflineSettlementStatus = offlineSettlementStatusSettled @@ -422,6 +440,9 @@ func (r *Repository) AdminMarkOfflineSettlement(ctx context.Context, adminID uin order.OfflineSettledBy = &adminID order.OfflineSettledAt = &now order.OwnerSettledAt = &now + if err := appendOrderEvent(tx, order, "settlement", "offline_settlement_confirmed", processlog.ActorAdmin, adminID, processlog.ActorUser, &order.OwnerID, "确认已完成线下结算。", req.Remark, map[string]any{"offline_settlement_amount_cent": order.OfflineSettlementAmountCent}, before, nil); err != nil { + return err + } if err := appendAuditLog(tx, adminID, "order.offline_settlement", "order", order.ID, meta, map[string]any{ "order_id": order.ID, "order_no": order.OrderNo, diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 3c3108f..0282517 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -107,6 +107,8 @@ func (row orderRow) toAdminDTO() OrderDTO { GrowthPointsAwarded: row.GrowthPointsAwarded, GrowthPointsAwardedAt: row.GrowthPointsAwardedAt, AccountSnapshot: row.AccountSnapshot, + AccountSource: effectiveAccountSource(row.RentalOrder), + SourceChannel: row.SourceChannel, Status: row.Status, HandoffStatus: row.HandoffStatus, HandoffMode: effectiveHandoffMode(row.RentalOrder), @@ -131,6 +133,13 @@ func (row orderRow) toAdminDTO() OrderDTO { } } +func effectiveAccountSource(order model.RentalOrder) string { + if order.AccountSource != "" { + return order.AccountSource + } + return "internal" +} + func (row orderRow) toDTOForUser(userID uint64) OrderDTO { dto := row.toAdminDTO() dto.AdminActions = nil @@ -146,6 +155,9 @@ func (row orderRow) toDTOForUser(userID uint64) OrderDTO { dto.OfflineSettlementRemark = "" dto.OfflineSettledBy = nil dto.OfflineSettledAt = nil + // 外部来源渠道属于后台经营信息,仅在管理员订单详情展示。 + dto.AccountSource = "" + dto.SourceChannel = "" applyOrderPriceView(&dto, row.RentalOrder, userID) return dto } diff --git a/backend/internal/modules/order/process_events.go b/backend/internal/modules/order/process_events.go new file mode 100644 index 0000000..97343fd --- /dev/null +++ b/backend/internal/modules/order/process_events.go @@ -0,0 +1,64 @@ +package order + +import ( + "context" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/processlog" + + "gorm.io/gorm" +) + +const processBusinessOrder = "order" + +func orderState(order model.RentalOrder) map[string]any { + return map[string]any{ + "order_status": order.Status, "handoff_status": order.HandoffStatus, + "settlement_status": order.SettlementStatus, + "offline_settlement_status": effectiveOfflineSettlementStatus(order), + } +} + +func checkoutEventPayload(checkout model.OrderCheckout) map[string]any { + return map[string]any{ + "checkout_id": checkout.ID, "round": checkout.RoundCount, "turn": normalizeCheckoutTurn(&checkout), + "proposed_by": checkout.ProposedBy, "rent_amount_cent": checkout.RentAmountCent, + "owner_rent_amount_cent": checkout.OwnerRentAmountCent, "platform_fee_cent": checkout.PlatformFeeCent, + "deposit_amount_cent": checkout.DepositAmountCent, "consumable_amount_cent": checkout.ConsumableAmountCent, + "coin_consumed_m": checkout.CoinConsumedM, "deposit_deduct_amount_cent": checkout.DepositDeductAmountCent, + "renter_refund_amount_cent": checkout.RenterRefundAmountCent, "owner_income_amount_cent": checkout.OwnerIncomeAmountCent, + "shortfall_cent": checkout.ShortfallCent, "overshoot_amount_cent": checkout.OvershootAmountCent, + } +} + +func appendOrderEvent(tx *gorm.DB, order model.RentalOrder, stage, action, actorType string, actorID uint64, targetType string, targetID *uint64, content, reason string, payload, before map[string]any, attachments []string) error { + return processlog.Append(tx, processlog.Entry{ + BusinessType: processBusinessOrder, BusinessID: order.ID, Stage: stage, Action: action, + ActorType: actorType, ActorID: actorID, TargetType: targetType, TargetID: targetID, + Content: content, Reason: reason, Payload: payload, Attachments: attachments, + StateBefore: before, StateAfter: orderState(order), + }) +} + +func (r *Repository) ProcessEventsAdmin(ctx context.Context, orderID uint64) ([]ProcessEventDTO, error) { + var order model.RentalOrder + if err := r.db.WithContext(ctx).First(&order, orderID).Error; err != nil { + return nil, err + } + rows, err := processlog.List(r.db.WithContext(ctx), processBusinessOrder, orderID) + if err != nil { + return nil, err + } + items := make([]ProcessEventDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, ProcessEventDTO{ + ID: row.ID, BusinessType: row.BusinessType, BusinessID: row.BusinessID, Stage: row.Stage, + Action: row.Action, ActorType: row.ActorType, ActorID: row.ActorID, ActorName: row.ActorName, + TargetType: row.TargetType, TargetID: row.TargetID, TargetName: row.TargetName, + Content: row.Content, Reason: row.Reason, Payload: row.Payload, + AttachmentURLs: decodeStringList(row.AttachmentURLs), StateBefore: row.StateBefore, + StateAfter: row.StateAfter, CreatedAt: row.CreatedAt, + }) + } + return items, nil +} diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index 8e7e5a8..c08605c 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -184,6 +184,13 @@ func (s *Service) HandoffRecordsAdmin(ctx context.Context, orderID uint64) ([]Ha return s.repo.HandoffRecordsAdmin(ctx, orderID) } +func (s *Service) ProcessEventsAdmin(ctx context.Context, orderID uint64) ([]ProcessEventDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ProcessEventsAdmin(ctx, orderID) +} + func (s *Service) AdminClose(ctx context.Context, adminID uint64, orderID uint64, req AdminActionRequest, meta AuditMeta) error { if s.repo == nil { return ErrDependencyUnavailable diff --git a/backend/internal/modules/pickup/handler.go b/backend/internal/modules/pickup/handler.go index 287da59..0d3a51b 100644 --- a/backend/internal/modules/pickup/handler.go +++ b/backend/internal/modules/pickup/handler.go @@ -150,6 +150,19 @@ func (h *Handler) ListFinancialAdjustments(c *gin.Context) { response.OK(c, items) } +func (h *Handler) ProcessEvents(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + items, err := h.service.ProcessEvents(c.Request.Context(), id) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + // SettleFinancialAdjustment 确认代管提号的补款或追回调整已线下处理。 func (h *Handler) SettleFinancialAdjustment(c *gin.Context) { adminID, ok := currentAdminID(c) diff --git a/backend/internal/modules/pickup/process_events.go b/backend/internal/modules/pickup/process_events.go new file mode 100644 index 0000000..6fddb95 --- /dev/null +++ b/backend/internal/modules/pickup/process_events.go @@ -0,0 +1,76 @@ +package pickup + +import ( + "context" + "encoding/json" + + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/processlog" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +const processBusinessPickup = "pickup" + +type ProcessEventDTO struct { + ID uint64 `json:"id"` + Stage string `json:"stage"` + Action string `json:"action"` + ActorType string `json:"actor_type"` + ActorID uint64 `json:"actor_id"` + ActorName string `json:"actor_name"` + TargetType string `json:"target_type"` + TargetID *uint64 `json:"target_id,omitempty"` + TargetName string `json:"target_name"` + Content string `json:"content"` + Reason string `json:"reason"` + Payload datatypes.JSON `json:"payload"` + AttachmentURLs []string `json:"attachment_urls"` + StateBefore datatypes.JSON `json:"state_before"` + StateAfter datatypes.JSON `json:"state_after"` + CreatedAt string `json:"created_at"` +} + +func pickupState(row model.AdminPickup) map[string]any { + return map[string]any{ + "pickup_status": row.Status, "offline_settlement_status": row.OfflineSettlementStatus, + "settlement_mode": row.SettlementMode, + } +} + +func appendPickupEvent(tx *gorm.DB, pickup model.AdminPickup, action string, adminID uint64, content, reason string, payload, before map[string]any) error { + return processlog.Append(tx, processlog.Entry{ + BusinessType: processBusinessPickup, BusinessID: pickup.ID, Stage: "pickup", Action: action, + ActorType: processlog.ActorAdmin, ActorID: adminID, TargetType: processlog.ActorUser, + TargetID: &pickup.OwnerID, Content: content, Reason: reason, Payload: payload, + StateBefore: before, StateAfter: pickupState(pickup), + }) +} + +func (r *Repository) ProcessEvents(ctx context.Context, pickupID uint64) ([]ProcessEventDTO, error) { + var pickup model.AdminPickup + if err := r.db.WithContext(ctx).First(&pickup, pickupID).Error; err != nil { + return nil, err + } + rows, err := processlog.List(r.db.WithContext(ctx), processBusinessPickup, pickupID) + if err != nil { + return nil, err + } + items := make([]ProcessEventDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, ProcessEventDTO{ + ID: row.ID, Stage: row.Stage, Action: row.Action, ActorType: row.ActorType, ActorID: row.ActorID, + ActorName: row.ActorName, TargetType: row.TargetType, TargetID: row.TargetID, TargetName: row.TargetName, + Content: row.Content, Reason: row.Reason, Payload: row.Payload, AttachmentURLs: decodeStringList(row.AttachmentURLs), + StateBefore: row.StateBefore, StateAfter: row.StateAfter, CreatedAt: row.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) + } + return items, nil +} + +func decodeStringList(raw datatypes.JSON) []string { + items := make([]string, 0) + _ = json.Unmarshal(raw, &items) + return items +} diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index 2552ebd..e2f1937 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -105,6 +105,13 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint if err := tx.Create(&pickup).Error; err != nil { return err } + if err := appendPickupEvent(tx, pickup, "pickup_created", adminID, "管理员创建线下提号。", pickup.Remark, map[string]any{ + "listing_price_cent": pickup.ListingPriceCent, "profit_amount_cent": pickup.ProfitAmountCent, + "account_source": pickup.AccountSource, "source_channel": pickup.SourceChannel, + "settlement_mode": pickup.SettlementMode, + }, map[string]any{}); err != nil { + return err + } createdID = pickup.ID // 锁定商品:status=rented,从「已上架」列表移除,进入「已锁定」 @@ -188,6 +195,7 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete if pickup.Status != StatusPickingUp { return ErrPickupNotPickingUp } + before := pickupState(pickup) var listing model.RentalListing if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, pickup.ListingID).Error; err != nil { @@ -273,6 +281,12 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete return err } } + if err := appendPickupEvent(tx, pickup, "pickup_completed", adminID, "管理员完成提号。", pickup.CompleteRemark, map[string]any{ + "settle_amount_cent": pickup.SettleAmountCent, "profit_amount_cent": pickup.ProfitAmountCent, + "settlement_mode": pickup.SettlementMode, "offline_settlement_status": pickup.OfflineSettlementStatus, + }, before); err != nil { + return err + } if err := auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", @@ -316,6 +330,7 @@ func (r *Repository) MarkOfflineSettlement(ctx context.Context, pickupID uint64, pickup.OfflineSettlementStatus != OfflineSettlementStatusPending || pickup.SettleAmountCent <= 0 { return ErrOfflineSettlementCannotMark } + before := pickupState(pickup) pendingTotals, err := pendingPickupFinancialAdjustmentTotals(tx, pickup.ID) if err != nil { return err @@ -345,6 +360,11 @@ func (r *Repository) MarkOfflineSettlement(ctx context.Context, pickupID uint64, return err } } + if err := appendPickupEvent(tx, pickup, "pickup_offline_settlement_confirmed", adminID, "确认已完成提号线下结算。", pickup.OfflineSettlementRemark, map[string]any{ + "settle_amount_cent": effectiveSettleAmountCent, "settled_adjustment_count": pendingTotals.Count, + }, before); err != nil { + return err + } bid := pickup.ID return auditlog.Append(tx, auditlog.Entry{ @@ -386,12 +406,18 @@ func (r *Repository) UpdateProfit(ctx context.Context, pickupID uint64, req Upda if pickup.Status != StatusPickingUp { return ErrPickupNotPickingUp } + before := pickupState(pickup) oldProfitAmountCent := pickup.ProfitAmountCent pickup.ProfitAmountCent = req.ProfitAmountCent if err := tx.Model(&pickup).Update("profit_amount_cent", pickup.ProfitAmountCent).Error; err != nil { return err } + if err := appendPickupEvent(tx, pickup, "pickup_profit_updated", adminID, "修改提号利润。", strings.TrimSpace(req.Reason), map[string]any{ + "old_profit_amount_cent": oldProfitAmountCent, "profit_amount_cent": pickup.ProfitAmountCent, + }, before); err != nil { + return err + } bid := pickup.ID return auditlog.Append(tx, auditlog.Entry{ @@ -431,6 +457,7 @@ func (r *Repository) CreateFinancialAdjustment(ctx context.Context, pickupID uin if pickup.Status != StatusCompleted { return ErrFinancialAdjustmentInvalid } + before := pickupState(pickup) totals, err := pickupFinancialAdjustmentTotals(tx, pickup.ID) if err != nil { @@ -471,6 +498,13 @@ func (r *Repository) CreateFinancialAdjustment(ctx context.Context, pickupID uin if err := tx.Create(&adjustment).Error; err != nil { return err } + if err := appendPickupEvent(tx, pickup, "pickup_financial_adjusted", adminID, "创建提号财务调整。", adjustment.Reason, map[string]any{ + "adjustment_id": adjustment.ID, "profit_delta_cent": adjustment.ProfitDeltaCent, + "settle_delta_cent": adjustment.SettleDeltaCent, "settlement_mode": adjustment.SettlementMode, + "adjustment_status": adjustment.Status, + }, before); err != nil { + return err + } // 站内钱包的补款在创建调整时立即到账;扣款统一转待追回,避免余额被扣成负数。 if pickup.SettlementMode == SettlementModeOwnerWallet && settleDeltaCent > 0 { @@ -559,6 +593,17 @@ func (r *Repository) SettleFinancialAdjustment(ctx context.Context, adjustmentID if err := tx.Save(&adjustment).Error; err != nil { return err } + var pickup model.AdminPickup + if err := tx.First(&pickup, adjustment.PickupID).Error; err != nil { + return err + } + if err := appendPickupEvent(tx, pickup, "pickup_adjustment_settled", adminID, "确认提号财务调整已处理。", adjustment.SettlementRemark, map[string]any{ + "adjustment_id": adjustment.ID, "profit_delta_cent": adjustment.ProfitDeltaCent, + "settle_delta_cent": adjustment.SettleDeltaCent, "before_status": beforeStatus, + "adjustment_status": adjustment.Status, + }, pickupState(pickup)); err != nil { + return err + } bid := adjustment.ID return auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", @@ -595,6 +640,7 @@ func (r *Repository) Cancel(ctx context.Context, pickupID uint64, reason string, if pickup.Status != StatusPickingUp { return ErrPickupNotPickingUp } + before := pickupState(pickup) var listing model.RentalListing if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, pickup.ListingID).Error; err != nil { return err @@ -609,6 +655,9 @@ func (r *Repository) Cancel(ctx context.Context, pickupID uint64, reason string, if err := tx.Save(&pickup).Error; err != nil { return err } + if err := appendPickupEvent(tx, pickup, "pickup_cancelled", adminID, "管理员取消提号。", reason, nil, before); err != nil { + return err + } // 取消提号:恢复上架,重新出现在商品管理「已上架」 fromStatus := listing.Status listing.InTransaction = false diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index 1f1a5a2..4d065e4 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -607,6 +607,7 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) { } if err := db.AutoMigrate( &model.User{}, + &model.AdminUser{}, &model.GameAccount{}, &model.RentalListing{}, &model.ListingUpload{}, @@ -615,6 +616,7 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) { &model.WalletAccount{}, &model.WalletLedger{}, &model.AuditLog{}, + &model.ProcessEvent{}, &model.Notification{}, &model.ListingStatusEvent{}, ); err != nil { diff --git a/backend/internal/modules/pickup/service.go b/backend/internal/modules/pickup/service.go index 874aff5..45f6c40 100644 --- a/backend/internal/modules/pickup/service.go +++ b/backend/internal/modules/pickup/service.go @@ -87,6 +87,13 @@ func (s *Service) ListFinancialAdjustments(ctx context.Context, pickupID uint64) return s.repo.ListFinancialAdjustments(ctx, pickupID) } +func (s *Service) ProcessEvents(ctx context.Context, pickupID uint64) ([]ProcessEventDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ProcessEvents(ctx, pickupID) +} + func (s *Service) SettleFinancialAdjustment(ctx context.Context, adjustmentID uint64, req FinancialAdjustmentSettlementRequest, adminID uint64, meta auditlog.Meta) error { if s.repo == nil { return ErrDependencyUnavailable diff --git a/backend/internal/processlog/processlog.go b/backend/internal/processlog/processlog.go new file mode 100644 index 0000000..8fc2514 --- /dev/null +++ b/backend/internal/processlog/processlog.go @@ -0,0 +1,128 @@ +// Package processlog 提供交易过程的追加式留痕能力。 +package processlog + +import ( + "encoding/json" + "strconv" + "strings" + + "hfb_sys/backend/internal/model" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +const ( + ActorUser = "user" + ActorAdmin = "admin" + ActorSystem = "system" +) + +type Entry struct { + BusinessType string + BusinessID uint64 + Stage string + Action string + ActorType string + ActorID uint64 + TargetType string + TargetID *uint64 + Content string + Reason string + Payload map[string]any + Attachments []string + StateBefore map[string]any + StateAfter map[string]any +} + +func Append(tx *gorm.DB, entry Entry) error { + actorName, err := displayName(tx, entry.ActorType, entry.ActorID) + if err != nil { + return err + } + targetName := "" + if entry.TargetID != nil && *entry.TargetID > 0 { + targetName, err = displayName(tx, entry.TargetType, *entry.TargetID) + if err != nil { + return err + } + } + return tx.Create(&model.ProcessEvent{ + BusinessType: entry.BusinessType, + BusinessID: entry.BusinessID, + Stage: entry.Stage, + Action: entry.Action, + ActorType: entry.ActorType, + ActorID: entry.ActorID, + ActorName: actorName, + TargetType: entry.TargetType, + TargetID: entry.TargetID, + TargetName: targetName, + Content: strings.TrimSpace(entry.Content), + Reason: strings.TrimSpace(entry.Reason), + Payload: jsonValue(entry.Payload), + AttachmentURLs: jsonValue(entry.Attachments), + StateBefore: jsonValue(entry.StateBefore), + StateAfter: jsonValue(entry.StateAfter), + }).Error +} + +func List(tx *gorm.DB, businessType string, businessID uint64) ([]model.ProcessEvent, error) { + var rows []model.ProcessEvent + err := tx.Where("business_type = ? AND business_id = ?", businessType, businessID). + Order("id ASC").Find(&rows).Error + return rows, err +} + +func displayName(tx *gorm.DB, actorType string, id uint64) (string, error) { + if id == 0 || actorType == ActorSystem { + return "系统", nil + } + switch actorType { + case ActorAdmin: + var row model.AdminUser + if err := tx.Select("id", "username", "nickname").First(&row, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return "客服 ID " + formatID(id), nil + } + return "", err + } + if strings.TrimSpace(row.Nickname) != "" { + return row.Nickname + "(客服)", nil + } + if strings.TrimSpace(row.Username) != "" { + return row.Username + "(客服)", nil + } + return "客服 ID " + formatID(id), nil + default: + var row model.User + if err := tx.Select("id", "nickname", "phone").First(&row, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return "用户 ID " + formatID(id), nil + } + return "", err + } + if strings.TrimSpace(row.Nickname) != "" { + return row.Nickname + "(用户)", nil + } + if strings.TrimSpace(row.Phone) != "" { + return row.Phone + "(用户)", nil + } + return "用户 ID " + formatID(id), nil + } +} + +func jsonValue(value any) datatypes.JSON { + if value == nil { + return datatypes.JSON([]byte("{}")) + } + raw, err := json.Marshal(value) + if err != nil || len(raw) == 0 { + return datatypes.JSON([]byte("{}")) + } + return datatypes.JSON(raw) +} + +func formatID(id uint64) string { + return strconv.FormatUint(id, 10) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 130a5b3..3786bde 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -603,6 +603,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing) adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail) adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords) + adminRoutes.GET("/orders/:id/process-events", requirePerm("order:view"), orderHandler.AdminProcessEvents) adminRoutes.POST("/orders/:id/close", requirePerm("order:close"), orderHandler.AdminClose) adminRoutes.POST("/orders/:id/seal", requirePerm("order:close"), orderHandler.AdminSeal) adminRoutes.POST("/orders/:id/mark-abnormal", requirePerm("order:mark_abnormal"), orderHandler.AdminMarkAbnormal) @@ -629,6 +630,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/pickups/shop-options", requirePerm("order:pickup"), pickupHandler.ShopOptions) adminRoutes.GET("/pickups/available-listings", requirePerm("order:pickup"), pickupHandler.AvailableListings) adminRoutes.GET("/pickups/:id/financial-adjustments", requirePerm("order:pickup"), pickupHandler.ListFinancialAdjustments) + adminRoutes.GET("/pickups/:id/process-events", requirePerm("order:pickup"), pickupHandler.ProcessEvents) adminRoutes.POST("/pickups/:id/financial-adjustments", requirePerm("order:pickup"), pickupHandler.CreateFinancialAdjustment) adminRoutes.GET("/pickups/:id", requirePerm("order:pickup"), pickupHandler.Detail) adminRoutes.POST("/pickups/:id/complete", requirePerm("order:pickup"), pickupHandler.Complete) diff --git a/backend/migrations/000058_process_events_and_order_source.sql b/backend/migrations/000058_process_events_and_order_source.sql new file mode 100644 index 0000000..c5213ee --- /dev/null +++ b/backend/migrations/000058_process_events_and_order_source.sql @@ -0,0 +1,37 @@ +-- +goose Up + +CREATE TABLE process_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + business_type VARCHAR(32) NOT NULL, + business_id BIGINT UNSIGNED NOT NULL, + stage VARCHAR(32) NOT NULL DEFAULT '', + action VARCHAR(64) NOT NULL, + actor_type VARCHAR(16) NOT NULL, + actor_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + actor_name VARCHAR(128) NOT NULL DEFAULT '', + target_type VARCHAR(16) NOT NULL DEFAULT '', + target_id BIGINT UNSIGNED NULL, + target_name VARCHAR(128) NOT NULL DEFAULT '', + content TEXT NOT NULL, + reason TEXT NOT NULL, + payload JSON NOT NULL, + attachment_urls JSON NOT NULL, + state_before JSON NOT NULL, + state_after JSON NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_process_event_business (business_type, business_id, id), + KEY idx_process_event_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单与提号不可变过程留痕'; + +ALTER TABLE rental_orders + ADD COLUMN account_source VARCHAR(32) NOT NULL DEFAULT 'internal' COMMENT '订单创建时账号来源快照' AFTER account_snapshot, + ADD COLUMN source_channel VARCHAR(32) NOT NULL DEFAULT '' COMMENT '订单创建时外部来源渠道快照' AFTER account_source; + +-- +goose Down + +ALTER TABLE rental_orders + DROP COLUMN source_channel, + DROP COLUMN account_source; + +DROP TABLE IF EXISTS process_events; diff --git a/frontend/src/features/admin/api/adminPickup.ts b/frontend/src/features/admin/api/adminPickup.ts index daff9f5..d64d3b5 100644 --- a/frontend/src/features/admin/api/adminPickup.ts +++ b/frontend/src/features/admin/api/adminPickup.ts @@ -63,6 +63,25 @@ export interface AdminPickupFinancialAdjustment { created_at: string } +export interface AdminPickupProcessEvent { + id: number + stage: string + action: string + actor_type: string + actor_id: number + actor_name: string + target_type: string + target_id?: number + target_name: string + content: string + reason: string + payload: Record + attachment_urls: string[] + state_before: Record + state_after: Record + created_at: string +} + export interface AvailableListing { id: number listing_no: string @@ -226,6 +245,13 @@ export async function fetchAdminPickupFinancialAdjustments(id: string | number) return Array.isArray(data.data) ? data.data : [] } +export async function fetchAdminPickupProcessEvents(id: string | number) { + const { data } = await apiClient.get>( + `/admin/pickups/${id}/process-events` + ) + return Array.isArray(data.data?.items) ? data.data.items : [] +} + export async function settleAdminPickupFinancialAdjustment( id: number, req: AdminPickupFinancialAdjustmentSettlementRequest = {} diff --git a/frontend/src/features/admin/components/ProcessTimeline.vue b/frontend/src/features/admin/components/ProcessTimeline.vue new file mode 100644 index 0000000..cfdfa6f --- /dev/null +++ b/frontend/src/features/admin/components/ProcessTimeline.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index 145ca73..fcb18d1 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -20,9 +20,11 @@ import { adminResetHandoff, adminSealOrder, fetchAdminHandoffRecords, + fetchAdminOrderProcessEvents, fetchAdminOrder, type HandoffRecord, type Order, + type ProcessEvent, type RefundStatus, } from '@/features/orders' import { adminCreateOrderDispute } from '@/features/disputes' @@ -44,6 +46,7 @@ import { } from '@/shared/utils/statusLabels' import { formatDateTime } from '@/shared/utils/time' import { formatGameName, formatListingNo } from '@/shared/utils/listingDisplay' +import ProcessTimeline from '../components/ProcessTimeline.vue' const route = useRoute() const router = useRouter() @@ -51,6 +54,7 @@ const loading = ref(false) const submitting = ref(false) const order = ref(null) const handoffRecords = ref([]) +const processEvents = ref([]) const paymentRecords = ref([]) type OrderActionType = | 'close' @@ -355,8 +359,14 @@ async function loadOrder() { loading.value = true try { order.value = await fetchAdminOrder(String(route.params.id)) - handoffRecords.value = await fetchAdminHandoffRecords(String(route.params.id)) - paymentRecords.value = (await fetchAdminPayments({ order_id: String(route.params.id) })).items + const [handoffs, events, payments] = await Promise.all([ + fetchAdminHandoffRecords(String(route.params.id)), + fetchAdminOrderProcessEvents(String(route.params.id)), + fetchAdminPayments({ order_id: String(route.params.id) }), + ]) + handoffRecords.value = handoffs + processEvents.value = events + paymentRecords.value = payments.items await loadRefundStatus() } finally { loading.value = false @@ -783,6 +793,10 @@ function settlementModeLabel(mode?: string) { return map[mode || 'owner_wallet'] || mode || '-' } +function accountSourceLabel(source?: string) { + return source === 'external' ? '外部上传' : '站内上传' +} + function offlineSettlementStatusLabel(status?: string) { const map: Record = { none: '无需线下结算', @@ -1132,6 +1146,34 @@ function paymentPaidAt(record: AdminPayment) { +
+
+

账号来源与处理方式

+
+
+
+
账号来源
+
{{ accountSourceLabel(order.account_source) }}
+
+
+
外部渠道
+
{{ order.source_channel }}
+
+
+
交接责任
+
{{ handoffModeLabel(order.handoff_mode) }}
+
+
+
结算方式
+
{{ settlementModeLabel(order.settlement_mode) }}
+
+
+
负责客服
+
ID {{ order.managed_admin_id }}
+
+
+
+

平台代管

@@ -1373,6 +1415,17 @@ function paymentPaidAt(record: AdminPayment) {
+
+
+

交易全流程

+ 每次提交、修改、确认及结算均单独留痕 +
+ +
+

交接记录

diff --git a/frontend/src/features/admin/views/AdminPickupDetailView.vue b/frontend/src/features/admin/views/AdminPickupDetailView.vue index af09824..0d0a373 100644 --- a/frontend/src/features/admin/views/AdminPickupDetailView.vue +++ b/frontend/src/features/admin/views/AdminPickupDetailView.vue @@ -7,11 +7,13 @@ import { useRoute } from 'vue-router' import { fetchAdminPickup, fetchAdminPickupFinancialAdjustments, + fetchAdminPickupProcessEvents, createAdminPickupFinancialAdjustment, settleAdminPickupFinancialAdjustment, updateAdminPickupProfit, type AdminPickup, type AdminPickupFinancialAdjustment, + type AdminPickupProcessEvent, } from '@/features/admin/api/adminPickup' import { quantity, readNumber, readUnitPrice } from '@/features/orders/composables/useOrderSnapshot' import { adminPath } from '@/shared/utils/adminPath' @@ -24,6 +26,7 @@ import { } from '@/shared/utils/money' import { pickupStatusLabel } from '@/shared/utils/statusLabels' import { formatDateTime } from '@/shared/utils/time' +import ProcessTimeline from '../components/ProcessTimeline.vue' interface SnapshotResource { key: string @@ -38,6 +41,7 @@ const route = useRoute() const loading = ref(false) const pickup = ref(null) const financialAdjustments = ref([]) +const processEvents = ref([]) const profitDialogVisible = ref(false) const financialAdjustmentDialogVisible = ref(false) const profitSaving = ref(false) @@ -117,12 +121,14 @@ onMounted(loadPickup) async function loadPickup() { loading.value = true try { - const [item, adjustments] = await Promise.all([ + const [item, adjustments, events] = await Promise.all([ fetchAdminPickup(String(route.params.id)), fetchAdminPickupFinancialAdjustments(String(route.params.id)), + fetchAdminPickupProcessEvents(String(route.params.id)), ]) pickup.value = item financialAdjustments.value = adjustments + processEvents.value = events } finally { loading.value = false } @@ -467,6 +473,17 @@ function readSnapshotResources(summary: Record | null): Snapsho
+
+
+

提号全流程

+ 创建、完成、调整及线下结算均单独留痕 +
+ +
+
+ account_source?: 'internal' | 'external' | string + source_channel?: string listing_snapshot?: string checkout_info?: string counter_info?: string @@ -149,6 +151,27 @@ export interface HandoffRecord { created_at: string } +export interface ProcessEvent { + id: number + business_type: string + business_id: number + stage: string + action: string + actor_type: 'user' | 'admin' | 'system' | string + actor_id: number + actor_name: string + target_type: string + target_id?: number + target_name: string + content: string + reason: string + payload: Record + attachment_urls: string[] + state_before: Record + state_after: Record + created_at: string +} + export interface PaymentOrder { id: number payment_no: string @@ -288,6 +311,13 @@ export async function fetchHandoffRecords(id: string | number) { return Array.isArray(data.data?.items) ? data.data.items : [] } +export async function fetchAdminOrderProcessEvents(id: string | number) { + const { data } = await apiClient.get>( + `/admin/orders/${id}/process-events` + ) + return Array.isArray(data.data?.items) ? data.data.items : [] +} + export async function confirmReceive(id: number) { const { data } = await apiClient.post>( `/orders/${id}/confirm-receive`