diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 6bfd1ed..057896b 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -23,6 +23,7 @@ type OrderDTO struct { LoginPlatform string `json:"login_platform"` RentedAt *time.Time `json:"rented_at"` EstimatedDurationHours int `json:"estimated_duration_hours"` + EstimatedEndAt *time.Time `json:"estimated_end_at,omitempty"` PriceRole string `json:"price_role,omitempty"` DisplayAmountCent int64 `json:"display_amount_cent"` RentAmountCent *int64 `json:"rent_amount_cent,omitempty"` diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index c4fa522..c2201bf 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -40,7 +40,7 @@ func (r *Repository) Create(ctx context.Context, renterID uint64, req CreateRequ if err != nil { return err } - rentHours := internalOrderHours + rentHours := estimateOrderDurationHours(snapshot) pricing := buildOrderPricing(listing, account) depositOriginalAmountCent := listing.DepositAmountCent paidDepositAmountCent, waivedDepositAmountCent, err := r.depositAmountsForOrder(tx, renterID, depositOriginalAmountCent) @@ -339,8 +339,7 @@ func (r *Repository) ConfirmReceive(ctx context.Context, userID uint64, orderID } order.Status = orderStatusRenting order.HandoffStatus = handoffStatusReceived - durationHours := orderDurationHours(order) - order.EstimatedDurationHours = durationHours + order.EstimatedDurationHours = estimateOrderDurationHours(order.AccountSnapshot) order.RentedAt = &now orderID := order.ID if err := notification.Append(tx, notification.Entry{ diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index cb4efab..3cc14c6 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -11,6 +11,7 @@ import ( func (row orderRow) toAdminDTO() OrderDTO { rentedAt := row.RentedAt durationHours := orderDurationHours(row.RentalOrder) + estimatedEndAt := orderEstimatedEndAt(row.RentalOrder) rentAmountCent := row.RentAmountCent ownerRentAmountCent := row.OwnerRentAmountCent platformFeeCent := row.PlatformFeeCent @@ -29,6 +30,7 @@ func (row orderRow) toAdminDTO() OrderDTO { LoginPlatform: row.LoginPlatform, RentedAt: rentedAt, EstimatedDurationHours: durationHours, + EstimatedEndAt: estimatedEndAt, PriceRole: "admin", DisplayAmountCent: row.RentAmountCent, RentAmountCent: &rentAmountCent, diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index f6742f8..6c9d19b 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -4,6 +4,7 @@ import ( "encoding/json" "math" "strconv" + "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/pkg/money" @@ -35,6 +36,38 @@ func orderDurationHours(order model.RentalOrder) int { return internalOrderHours } +func orderEstimatedEndAt(order model.RentalOrder) *time.Time { + if order.RentedAt == nil { + return nil + } + durationHours := orderDurationHours(order) + if durationHours <= 0 { + return nil + } + endAt := order.RentedAt.Add(time.Duration(durationHours) * time.Hour) + return &endAt +} + +func estimateOrderDurationHours(snapshot datatypes.JSON) int { + hafCoinM := readOrderSnapshotCoinM(snapshot) + if hafCoinM <= 0 { + return internalOrderHours + } + dailyLossM := readOrderSnapshotAssetNumber(snapshot, "daily_loss_m") + if dailyLossM <= 0 { + return internalOrderHours + } + days := hafCoinM / dailyLossM + if days <= 0 { + return internalOrderHours + } + hours := int(math.Round(days * 24)) + if hours <= 0 { + return internalOrderHours + } + return hours +} + func buildOrderPricing(listing model.RentalListing, account model.GameAccount) orderPricing { rentAmountCent := listing.PriceCent ownerRentAmountCent := int64(math.Round(readSnapshotPrice(account.AssetSummary, "seller_total_price") * 100)) @@ -203,6 +236,24 @@ func readOrderSnapshotCoinM(raw datatypes.JSON) float64 { return roundQuantity(readJSONNumber(snapshot["haf_coin_amount"]) / 1000000) } +func readOrderSnapshotAssetNumber(raw datatypes.JSON, key string) float64 { + if len(raw) == 0 { + return 0 + } + var snapshot map[string]any + if err := json.Unmarshal(raw, &snapshot); err != nil { + return 0 + } + assetSummary, ok := snapshot["asset_summary"].(map[string]any) + if !ok { + return readJSONNumber(snapshot[key]) + } + if value, ok := assetSummary[key]; ok { + return readJSONNumber(value) + } + return readJSONNumber(snapshot[key]) +} + // roundMoney 使用统一的角精度(0.1元) func roundMoney(value float64) float64 { return money.Round(value) diff --git a/backend/internal/modules/order/repository_integration_test.go b/backend/internal/modules/order/repository_integration_test.go index 11305cd..0b0a5ab 100644 --- a/backend/internal/modules/order/repository_integration_test.go +++ b/backend/internal/modules/order/repository_integration_test.go @@ -2,9 +2,11 @@ package order import ( "testing" + "time" "hfb_sys/backend/internal/model" + "gorm.io/datatypes" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -378,6 +380,107 @@ func TestOrderDurationHoursUsesDefault(t *testing.T) { } } +func TestEstimateOrderDurationHoursFromSnapshot(t *testing.T) { + snapshot := datatypes.JSON([]byte(`{ + "haf_coin_amount": 165000000, + "asset_summary": { + "daily_loss_m": 10 + } + }`)) + + hours := estimateOrderDurationHours(snapshot) + if hours != 396 { + t.Fatalf("hours = %d, want 396", hours) + } +} + +func TestEstimateOrderDurationHoursUsesDefaultWithoutDailyLoss(t *testing.T) { + snapshot := datatypes.JSON([]byte(`{ + "haf_coin_amount": 165000000, + "asset_summary": {} + }`)) + + hours := estimateOrderDurationHours(snapshot) + if hours != internalOrderHours { + t.Fatalf("hours = %d, want %d", hours, internalOrderHours) + } +} + +func TestConfirmReceiveRecalculatesEstimatedDuration(t *testing.T) { + db := setupOrderTestDB(t) + repo := NewRepository(db) + + owner := model.User{Phone: "13800002001"} + renter := model.User{Phone: "13900002001"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("create owner failed: %v", err) + } + if err := db.Create(&renter).Error; err != nil { + t.Fatalf("create renter failed: %v", err) + } + + account := model.GameAccount{ + OwnerID: owner.ID, + Status: "rented", + ServerRegion: "国服", + LoginPlatform: "steam", + Title: "测试账号", + HafCoinAmount: 165000000, + AssetSummary: datatypes.JSON([]byte(`{"daily_loss_m":10}`)), + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("create account failed: %v", err) + } + + listing := model.RentalListing{ + ListingNo: "LST-DUR-001", + OwnerID: owner.ID, + AccountID: account.ID, + Status: "published", + ReviewStatus: "approved", + InTransaction: true, + PriceCent: 1000, + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("create listing failed: %v", err) + } + + snapshot, err := makeAccountSnapshot(account, listing) + if err != nil { + t.Fatalf("make snapshot failed: %v", err) + } + + order := model.RentalOrder{ + OrderNo: "ORD-DUR-001", + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + RenterID: renter.ID, + EstimatedDurationHours: internalOrderHours, + AccountSnapshot: snapshot, + Status: orderStatusPendingHandoff, + HandoffStatus: handoffStatusPendingRenterConfirm, + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("create order failed: %v", err) + } + + if err := repo.ConfirmReceive(t.Context(), renter.ID, order.ID); err != nil { + t.Fatalf("ConfirmReceive() error = %v", err) + } + + var saved model.RentalOrder + if err := db.First(&saved, order.ID).Error; err != nil { + t.Fatalf("load order failed: %v", err) + } + if saved.EstimatedDurationHours != 396 { + t.Fatalf("EstimatedDurationHours = %d, want 396", saved.EstimatedDurationHours) + } + if saved.RentedAt == nil || saved.RentedAt.After(time.Now()) { + t.Fatalf("RentedAt not set correctly: %#v", saved.RentedAt) + } +} + // TestCancelClosesPendingPaymentOrder 验证租客取消待支付订单时,未完成支付流水会同步关闭。 func TestCancelClosesPendingPaymentOrder(t *testing.T) { db := setupOrderTestDB(t) diff --git a/frontend/src/features/admin/views/AdminOrderDetailView.vue b/frontend/src/features/admin/views/AdminOrderDetailView.vue index a4ba560..c75e080 100644 --- a/frontend/src/features/admin/views/AdminOrderDetailView.vue +++ b/frontend/src/features/admin/views/AdminOrderDetailView.vue @@ -109,6 +109,7 @@ function orderRentedAt() { function orderEstimatedEndAt() { if (!order.value) return undefined + if (order.value.estimated_end_at) return order.value.estimated_end_at const rentedAt = orderRentedAt() const durationHours = Number(order.value.estimated_duration_hours || 0) if (!rentedAt || durationHours <= 0) return undefined diff --git a/frontend/src/features/orders/api/orders.ts b/frontend/src/features/orders/api/orders.ts index 08bc3a4..6919592 100644 --- a/frontend/src/features/orders/api/orders.ts +++ b/frontend/src/features/orders/api/orders.ts @@ -18,6 +18,7 @@ export interface Order { login_platform: string rented_at?: string estimated_duration_hours: number + estimated_end_at?: string price_role?: 'renter' | 'owner' | 'admin' | string display_amount_cent: number rent_amount_cent?: number diff --git a/frontend/src/features/orders/composables/useOrderSnapshot.ts b/frontend/src/features/orders/composables/useOrderSnapshot.ts index 97f07dd..b01c59d 100644 --- a/frontend/src/features/orders/composables/useOrderSnapshot.ts +++ b/frontend/src/features/orders/composables/useOrderSnapshot.ts @@ -211,6 +211,7 @@ export function getSnapshotHafCoinM(order: Order | null) { } export function orderEstimatedEndAt(order: Order | null) { + if (order?.estimated_end_at) return order.estimated_end_at const rentedAt = order?.rented_at const durationHours = Number(order?.estimated_duration_hours || 0) if (!rentedAt || durationHours <= 0) return undefined