完善订单交接结账流程留痕
This commit is contained in:
@@ -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{},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user