1252 lines
42 KiB
Go
1252 lines
42 KiB
Go
package pickup
|
|
|
|
import (
|
|
"context"
|
|
crand "crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"hfb_sys/backend/internal/auditlog"
|
|
"hfb_sys/backend/internal/listingstatus"
|
|
"hfb_sys/backend/internal/model"
|
|
"hfb_sys/backend/internal/modules/notification"
|
|
"hfb_sys/backend/internal/modules/wallet"
|
|
"hfb_sys/backend/internal/timeutil"
|
|
"hfb_sys/backend/pkg/money"
|
|
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type Repository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewRepository(db *gorm.DB) *Repository {
|
|
return &Repository{db: db}
|
|
}
|
|
|
|
// Create 创建提号订单(状态:提号中)。
|
|
// 与正常订单支付成功一致:listing 置 rented + in_transaction,从商品管理「已上架」中移出。
|
|
// 不结算、不动钱包。
|
|
func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
var createdID uint64
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var listing model.RentalListing
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, req.ListingID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrListingUnavailable
|
|
}
|
|
return err
|
|
}
|
|
if listing.Status != "published" || listing.ReviewStatus != "approved" || listing.InTransaction {
|
|
return ErrListingUnavailable
|
|
}
|
|
|
|
// 同一 listing 不能有进行中的提号
|
|
var cnt int64
|
|
if err := tx.Model(&model.AdminPickup{}).
|
|
Where("listing_id = ? AND status = ?", listing.ID, StatusPickingUp).
|
|
Count(&cnt).Error; err != nil {
|
|
return err
|
|
}
|
|
if cnt > 0 {
|
|
return ErrDuplicatePickup
|
|
}
|
|
|
|
var account model.GameAccount
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, listing.AccountID).Error; err != nil {
|
|
return err
|
|
}
|
|
accountSource, sourceChannel, err := resolvePickupAccountSource(tx, listing.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
priceSnapshot := buildPickupPriceSnapshot(listing, account)
|
|
accountSnapshot, err := makePickupAccountSnapshot(account, listing)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pickupNo, err := newPickupNo()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pickup := model.AdminPickup{
|
|
PickupNo: pickupNo,
|
|
ListingID: listing.ID,
|
|
AccountID: listing.AccountID,
|
|
OwnerID: listing.OwnerID,
|
|
AdminID: adminID,
|
|
Platform: strings.TrimSpace(req.Platform),
|
|
ShopName: strings.TrimSpace(req.ShopName),
|
|
AccountSource: accountSource,
|
|
SourceChannel: sourceChannel,
|
|
SettlementMode: pickupSettlementMode(listing.SettlementMode, accountSource),
|
|
ListingPriceCent: priceSnapshot.ListingPriceCent,
|
|
OwnerPriceCent: priceSnapshot.OwnerPriceCent,
|
|
WebsiteProfitCent: priceSnapshot.WebsiteProfitCent,
|
|
ProfitAmountCent: req.ProfitAmountCent,
|
|
SellerRatio: priceSnapshot.SellerRatio,
|
|
BuyerRatio: priceSnapshot.BuyerRatio,
|
|
AccountSnapshot: accountSnapshot,
|
|
Status: StatusPickingUp,
|
|
Remark: strings.TrimSpace(req.Remark),
|
|
}
|
|
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,从「已上架」列表移除,进入「已锁定」
|
|
fromStatus := listing.Status
|
|
listing.InTransaction = true
|
|
listing.Status = "rented"
|
|
account.Status = "rented"
|
|
if err := tx.Save(&listing).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(&account).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := listingstatus.AppendTransition(
|
|
tx,
|
|
&listing,
|
|
fromStatus,
|
|
listingstatus.SourcePickup,
|
|
listingstatus.ActorAdmin,
|
|
adminID,
|
|
"管理员线下提号锁定",
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
bid := pickup.ID
|
|
if err := notification.Append(tx, notification.Entry{
|
|
UserID: listing.OwnerID,
|
|
Type: "pickup",
|
|
Title: "账号被管理员提号",
|
|
Content: fmt.Sprintf("您的账号「%s」已被管理员提号(线下交易),等待完成结算。", account.Title),
|
|
BizType: "pickup",
|
|
BizID: &bid,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := auditlog.Append(tx, auditlog.Entry{
|
|
ActorType: "admin",
|
|
ActorID: adminID,
|
|
Action: "pickup_create",
|
|
BizType: "admin_pickup",
|
|
BizID: &bid,
|
|
Meta: meta,
|
|
Detail: map[string]any{
|
|
"pickup_no": pickupNo,
|
|
"listing_id": listing.ID,
|
|
"platform": pickup.Platform,
|
|
"shop_name": pickup.ShopName,
|
|
"account_source": pickup.AccountSource,
|
|
"source_channel": pickup.SourceChannel,
|
|
"profit_cent": pickup.ProfitAmountCent,
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return r.FindByID(ctx, createdID)
|
|
}
|
|
|
|
// Complete 完成提号。站内上传结算至卖家钱包;平台代管提号转为待线下结算。
|
|
func (r *Repository) Complete(ctx context.Context, pickupID uint64, req CompleteRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
if req.SettleAmountCent <= 0 {
|
|
return nil, ErrInvalidAmount
|
|
}
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var pickup model.AdminPickup
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pickup, pickupID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrPickupNotFound
|
|
}
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
var account model.GameAccount
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, pickup.AccountID).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
pickup.SettleAmountCent = req.SettleAmountCent
|
|
if req.ProfitAmountCent != nil {
|
|
pickup.ProfitAmountCent = *req.ProfitAmountCent
|
|
}
|
|
pickup.Status = StatusCompleted
|
|
pickup.CompleteRemark = strings.TrimSpace(req.CompleteRemark)
|
|
pickup.CompletedAt = &now
|
|
if err := tx.Save(&pickup).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// listing 置 completed(售出终态,不可再上架),与正常订单 completeAssets 对齐。
|
|
fromStatus := listing.Status
|
|
listing.InTransaction = false
|
|
listing.Status = "completed"
|
|
listing.PublishedAt = nil
|
|
account.Status = "offline"
|
|
if err := tx.Save(&listing).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(&account).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := listingstatus.AppendTransition(
|
|
tx,
|
|
&listing,
|
|
fromStatus,
|
|
listingstatus.SourcePickup,
|
|
listingstatus.ActorAdmin,
|
|
adminID,
|
|
"管理员提号完成",
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
bid := pickup.ID
|
|
if pickupRequiresOfflineSettlement(pickup) {
|
|
pickup.OfflineSettlementStatus = OfflineSettlementStatusPending
|
|
if err := tx.Save(&pickup).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := notification.Append(tx, notification.Entry{
|
|
UserID: pickup.OwnerID,
|
|
Type: "pickup",
|
|
Title: "提号已完成,待线下结算",
|
|
Content: fmt.Sprintf("账号「%s」提号完成,结算金额 %s 将通过线下方式支付。", account.Title, money.FormatWithSymbol(req.SettleAmountCent)),
|
|
BizType: "pickup",
|
|
BizID: &bid,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if err := wallet.AppendEntries(tx, wallet.Entry{
|
|
UserID: pickup.OwnerID,
|
|
Direction: "in",
|
|
AmountCent: req.SettleAmountCent,
|
|
BalanceType: "available",
|
|
BizType: "admin_pickup_settle",
|
|
BizNo: pickup.PickupNo,
|
|
Remark: fmt.Sprintf("管理员提号结算:%s", pickup.PickupNo),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := notification.Append(tx, notification.Entry{
|
|
UserID: pickup.OwnerID,
|
|
Type: "pickup",
|
|
Title: "提号已完成,已结算到账",
|
|
Content: fmt.Sprintf("账号「%s」提号完成,已结算 %s 到您的钱包。", account.Title, money.FormatWithSymbol(req.SettleAmountCent)),
|
|
BizType: "pickup",
|
|
BizID: &bid,
|
|
}); err != nil {
|
|
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",
|
|
ActorID: adminID,
|
|
Action: "pickup_complete",
|
|
BizType: "admin_pickup",
|
|
BizID: &bid,
|
|
Meta: meta,
|
|
Detail: map[string]any{
|
|
"pickup_no": pickup.PickupNo,
|
|
"settle_amount_cent": req.SettleAmountCent,
|
|
"profit_amount_cent": pickup.ProfitAmountCent,
|
|
"settlement_mode": pickup.SettlementMode,
|
|
"offline_settlement_status": pickup.OfflineSettlementStatus,
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return r.FindByID(ctx, pickupID)
|
|
}
|
|
|
|
// MarkOfflineSettlement 确认平台代管提号已完成实际线下打款。
|
|
func (r *Repository) MarkOfflineSettlement(ctx context.Context, pickupID uint64, req OfflineSettlementRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var pickup model.AdminPickup
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pickup, pickupID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrPickupNotFound
|
|
}
|
|
return err
|
|
}
|
|
if !pickupRequiresOfflineSettlement(pickup) || pickup.Status != StatusCompleted ||
|
|
pickup.OfflineSettlementStatus != OfflineSettlementStatusPending || pickup.SettleAmountCent <= 0 {
|
|
return ErrOfflineSettlementCannotMark
|
|
}
|
|
before := pickupState(pickup)
|
|
pendingTotals, err := pendingPickupFinancialAdjustmentTotals(tx, pickup.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
effectiveSettleAmountCent := pickup.SettleAmountCent + pendingTotals.SettleAdjustmentCent
|
|
if effectiveSettleAmountCent < 0 {
|
|
return ErrFinancialAdjustmentInvalid
|
|
}
|
|
|
|
now := time.Now()
|
|
pickup.OfflineSettlementStatus = OfflineSettlementStatusSettled
|
|
pickup.OfflineSettlementRemark = strings.TrimSpace(req.Remark)
|
|
pickup.OfflineSettledBy = &adminID
|
|
pickup.OfflineSettledAt = &now
|
|
if err := tx.Save(&pickup).Error; err != nil {
|
|
return err
|
|
}
|
|
if pendingTotals.Count > 0 {
|
|
if err := tx.Model(&model.AdminPickupFinancialAdjustment{}).
|
|
Where("pickup_id = ? AND status IN ?", pickup.ID, []string{FinancialAdjustmentStatusPending, FinancialAdjustmentStatusRecovery}).
|
|
Updates(map[string]any{
|
|
"status": FinancialAdjustmentStatusSettled,
|
|
"settled_by": adminID,
|
|
"settled_at": now,
|
|
"settlement_remark": pickup.OfflineSettlementRemark,
|
|
}).Error; err != nil {
|
|
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{
|
|
ActorType: "admin",
|
|
ActorID: adminID,
|
|
Action: "pickup_offline_settlement",
|
|
BizType: "admin_pickup",
|
|
BizID: &bid,
|
|
Meta: meta,
|
|
Detail: map[string]any{
|
|
"pickup_no": pickup.PickupNo,
|
|
"settle_amount_cent": effectiveSettleAmountCent,
|
|
"settled_adjustment_count": pendingTotals.Count,
|
|
"before_offline_settlement_status": OfflineSettlementStatusPending,
|
|
"after_offline_settlement_status": pickup.OfflineSettlementStatus,
|
|
"remark": pickup.OfflineSettlementRemark,
|
|
},
|
|
})
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return r.FindByID(ctx, pickupID)
|
|
}
|
|
|
|
// UpdateProfit 修改提号中的线下利润,已完成和已取消的提号不可修改。
|
|
func (r *Repository) UpdateProfit(ctx context.Context, pickupID uint64, req UpdateProfitRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var pickup model.AdminPickup
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pickup, pickupID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrPickupNotFound
|
|
}
|
|
return err
|
|
}
|
|
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{
|
|
ActorType: "admin",
|
|
ActorID: adminID,
|
|
Action: "pickup_profit_update",
|
|
BizType: "admin_pickup",
|
|
BizID: &bid,
|
|
Meta: meta,
|
|
Detail: map[string]any{
|
|
"pickup_no": pickup.PickupNo,
|
|
"old_profit_amount_cent": oldProfitAmountCent,
|
|
"new_profit_amount_cent": pickup.ProfitAmountCent,
|
|
"reason": strings.TrimSpace(req.Reason),
|
|
},
|
|
})
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return r.FindByID(ctx, pickupID)
|
|
}
|
|
|
|
// CreateFinancialAdjustment 为已完成提号追加财务差额,不改写完成时的原始金额。
|
|
func (r *Repository) CreateFinancialAdjustment(ctx context.Context, pickupID uint64, req FinancialAdjustmentRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var pickup model.AdminPickup
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pickup, pickupID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrPickupNotFound
|
|
}
|
|
return err
|
|
}
|
|
if pickup.Status != StatusCompleted {
|
|
return ErrFinancialAdjustmentInvalid
|
|
}
|
|
before := pickupState(pickup)
|
|
|
|
totals, err := pickupFinancialAdjustmentTotals(tx, pickup.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
profitDeltaCent := int64(0)
|
|
if req.ProfitAmountCent != nil {
|
|
profitDeltaCent = *req.ProfitAmountCent - (pickup.ProfitAmountCent + totals.ProfitAdjustmentCent)
|
|
}
|
|
settleDeltaCent := int64(0)
|
|
if req.SettleAmountCent != nil {
|
|
settleDeltaCent = *req.SettleAmountCent - (pickup.SettleAmountCent + totals.SettleAdjustmentCent)
|
|
}
|
|
if profitDeltaCent == 0 && settleDeltaCent == 0 {
|
|
return ErrFinancialAdjustmentInvalid
|
|
}
|
|
|
|
status := FinancialAdjustmentStatusSettled
|
|
if settleDeltaCent != 0 {
|
|
if pickupRequiresOfflineSettlement(pickup) && pickup.OfflineSettlementStatus == OfflineSettlementStatusPending {
|
|
// 原结算尚未付款时,差额随同一次线下打款处理。
|
|
status = FinancialAdjustmentStatusPending
|
|
} else if settleDeltaCent < 0 {
|
|
status = FinancialAdjustmentStatusRecovery
|
|
} else if pickupRequiresOfflineSettlement(pickup) {
|
|
status = FinancialAdjustmentStatusPending
|
|
}
|
|
}
|
|
adjustment := model.AdminPickupFinancialAdjustment{
|
|
PickupID: pickup.ID,
|
|
ProfitDeltaCent: profitDeltaCent,
|
|
SettleDeltaCent: settleDeltaCent,
|
|
SettlementMode: pickup.SettlementMode,
|
|
Status: status,
|
|
Reason: strings.TrimSpace(req.Reason),
|
|
CreatedBy: adminID,
|
|
}
|
|
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 {
|
|
if err := wallet.AppendEntries(tx, wallet.Entry{
|
|
UserID: pickup.OwnerID,
|
|
Direction: "in",
|
|
AmountCent: settleDeltaCent,
|
|
BalanceType: "available",
|
|
BizType: "admin_pickup_adjustment",
|
|
BizNo: fmt.Sprintf("%s-A%d", pickup.PickupNo, adjustment.ID),
|
|
Remark: fmt.Sprintf("管理员提号结算补款:%s", pickup.PickupNo),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
bid := adjustment.ID
|
|
return auditlog.Append(tx, auditlog.Entry{
|
|
ActorType: "admin",
|
|
ActorID: adminID,
|
|
Action: "pickup_financial_adjustment_create",
|
|
BizType: "admin_pickup",
|
|
BizID: &bid,
|
|
Meta: meta,
|
|
Detail: map[string]any{
|
|
"pickup_id": pickup.ID,
|
|
"pickup_no": pickup.PickupNo,
|
|
"profit_delta_cent": profitDeltaCent,
|
|
"settle_delta_cent": settleDeltaCent,
|
|
"settlement_mode": pickup.SettlementMode,
|
|
"adjustment_status": status,
|
|
"reason": adjustment.Reason,
|
|
},
|
|
})
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return r.FindByID(ctx, pickupID)
|
|
}
|
|
|
|
// ListFinancialAdjustments 返回提号的不可变财务调整流水。
|
|
func (r *Repository) ListFinancialAdjustments(ctx context.Context, pickupID uint64) ([]FinancialAdjustmentDTO, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
var count int64
|
|
if err := r.db.WithContext(ctx).Model(&model.AdminPickup{}).Where("id = ?", pickupID).Count(&count).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if count == 0 {
|
|
return nil, ErrPickupNotFound
|
|
}
|
|
items := make([]FinancialAdjustmentDTO, 0)
|
|
if err := r.db.WithContext(ctx).Model(&model.AdminPickupFinancialAdjustment{}).
|
|
Where("pickup_id = ?", pickupID).
|
|
Order("id DESC").
|
|
Find(&items).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// SettleFinancialAdjustment 确认平台代管的补款或追回调整已在线下处理。
|
|
func (r *Repository) SettleFinancialAdjustment(ctx context.Context, adjustmentID uint64, req FinancialAdjustmentSettlementRequest, adminID uint64, meta auditlog.Meta) error {
|
|
if r == nil || r.db == nil {
|
|
return ErrDependencyUnavailable
|
|
}
|
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var adjustment model.AdminPickupFinancialAdjustment
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&adjustment, adjustmentID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrFinancialAdjustmentNotFound
|
|
}
|
|
return err
|
|
}
|
|
if adjustment.Status != FinancialAdjustmentStatusPending && adjustment.Status != FinancialAdjustmentStatusRecovery {
|
|
return ErrFinancialAdjustmentCannotSettle
|
|
}
|
|
beforeStatus := adjustment.Status
|
|
now := time.Now()
|
|
adjustment.Status = FinancialAdjustmentStatusSettled
|
|
adjustment.SettledBy = &adminID
|
|
adjustment.SettledAt = &now
|
|
adjustment.SettlementRemark = strings.TrimSpace(req.Remark)
|
|
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",
|
|
ActorID: adminID,
|
|
Action: "pickup_financial_adjustment_settle",
|
|
BizType: "admin_pickup",
|
|
BizID: &bid,
|
|
Meta: meta,
|
|
Detail: map[string]any{
|
|
"pickup_id": adjustment.PickupID,
|
|
"profit_delta_cent": adjustment.ProfitDeltaCent,
|
|
"settle_delta_cent": adjustment.SettleDeltaCent,
|
|
"before_status": beforeStatus,
|
|
"after_status": adjustment.Status,
|
|
"settlement_remark": adjustment.SettlementRemark,
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
// Cancel 取消提号:恢复 listing 为 published + 解锁 in_transaction,账号恢复可租。
|
|
func (r *Repository) Cancel(ctx context.Context, pickupID uint64, reason string, adminID uint64, meta auditlog.Meta) error {
|
|
if r == nil || r.db == nil {
|
|
return ErrDependencyUnavailable
|
|
}
|
|
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var pickup model.AdminPickup
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pickup, pickupID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrPickupNotFound
|
|
}
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
var account model.GameAccount
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, pickup.AccountID).Error; err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
pickup.Status = StatusCancelled
|
|
pickup.CancelledAt = &now
|
|
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
|
|
listing.Status = "published"
|
|
account.Status = "published"
|
|
if err := tx.Save(&listing).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(&account).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := listingstatus.AppendTransition(
|
|
tx,
|
|
&listing,
|
|
fromStatus,
|
|
listingstatus.SourcePickup,
|
|
listingstatus.ActorAdmin,
|
|
adminID,
|
|
"取消提号恢复上架",
|
|
); err != nil {
|
|
return err
|
|
}
|
|
bid := pickup.ID
|
|
_ = notification.Append(tx, notification.Entry{
|
|
UserID: pickup.OwnerID,
|
|
Type: "pickup",
|
|
Title: "提号已取消",
|
|
Content: fmt.Sprintf("提号订单 %s 已取消,原因:%s", pickup.PickupNo, reason),
|
|
BizType: "pickup",
|
|
BizID: &bid,
|
|
})
|
|
_ = auditlog.Append(tx, auditlog.Entry{
|
|
ActorType: "admin",
|
|
ActorID: adminID,
|
|
Action: "pickup_cancel",
|
|
BizType: "admin_pickup",
|
|
BizID: &bid,
|
|
Meta: meta,
|
|
Detail: map[string]any{"pickup_no": pickup.PickupNo, "reason": reason},
|
|
})
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (r *Repository) FindByID(ctx context.Context, id uint64) (*PickupDTO, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
var row pickupRow
|
|
if err := r.baseQuery(ctx).Where("p.id = ?", id).First(&row).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrPickupNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
dto := row.toDTO()
|
|
if isEmptyPickupAccountSnapshot(dto.AccountSnapshot) {
|
|
if snapshot, err := r.fallbackAccountSnapshot(ctx, row.ListingID, row.AccountID); err == nil {
|
|
dto.AccountSnapshot = snapshot
|
|
}
|
|
}
|
|
return &dto, nil
|
|
}
|
|
|
|
func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*PaginatedResult, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
db := r.baseQuery(ctx)
|
|
if status := strings.TrimSpace(query.Status); status != "" {
|
|
db = db.Where("p.status = ?", status)
|
|
}
|
|
if shopName := strings.TrimSpace(query.ShopName); shopName != "" {
|
|
db = db.Where("p.shop_name = ?", shopName)
|
|
}
|
|
if accountSource := strings.TrimSpace(query.AccountSource); accountSource != "" {
|
|
db = db.Where("p.account_source = ?", accountSource)
|
|
}
|
|
if offlineSettlementStatus := strings.TrimSpace(query.OfflineSettlementStatus); offlineSettlementStatus != "" {
|
|
db = db.Where("p.offline_settlement_status = ?", offlineSettlementStatus)
|
|
}
|
|
if kw := strings.TrimSpace(query.Keyword); kw != "" {
|
|
like := "%" + kw + "%"
|
|
db = db.Where(
|
|
"p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ?",
|
|
like,
|
|
like,
|
|
like,
|
|
like,
|
|
)
|
|
}
|
|
return r.paginatePickups(db, query.Page, query.PageSize, false)
|
|
}
|
|
|
|
// ListShopNames 返回历史提号中已使用的店铺名称,供后台选择和筛选。
|
|
func (r *Repository) ListShopNames(ctx context.Context, platform string) ([]string, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
db := r.db.WithContext(ctx).Model(&model.AdminPickup{}).
|
|
Where("shop_name <> ''")
|
|
if value := strings.TrimSpace(platform); value != "" {
|
|
db = db.Where("platform = ?", value)
|
|
}
|
|
names := make([]string, 0)
|
|
if err := db.Distinct("shop_name").Order("shop_name ASC").Limit(200).Pluck("shop_name", &names).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query SellerPickupQuery) (*PaginatedResult, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
db := r.baseQuery(ctx).Where("p.owner_id = ?", ownerID)
|
|
if status := strings.TrimSpace(query.Status); status != "" {
|
|
db = db.Where("p.status = ?", status)
|
|
}
|
|
return r.paginatePickups(db, query.Page, query.PageSize, true)
|
|
}
|
|
|
|
func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int, sellerView bool) (*PaginatedResult, error) {
|
|
page, pageSize = normalizePaging(page, pageSize)
|
|
var total int64
|
|
if err := db.Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var rows []pickupRow
|
|
if err := db.Order("p.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]PickupDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
item := row.toDTO()
|
|
item.AccountSnapshot = nil
|
|
if sellerView {
|
|
item.WebsiteProfitCent = 0
|
|
item.ProfitAmountCent = 0
|
|
item.ProfitAdjustmentCent = 0
|
|
item.EffectiveProfitAmountCent = 0
|
|
item.BuyerRatio = 0
|
|
item.ShopName = ""
|
|
item.SourceChannel = ""
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
// ListAvailableListings 可提号的 listing:已发布、已审核、未在交易中。
|
|
func (r *Repository) ListAvailableListings(ctx context.Context, query AvailableListingQuery) (*AvailableListingsResult, error) {
|
|
if r == nil || r.db == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
latestUploads := r.db.WithContext(ctx).Table("listing_uploads").
|
|
Select("listing_id, MAX(id) AS upload_id").
|
|
Where("listing_id IS NOT NULL").
|
|
Group("listing_id")
|
|
db := r.db.WithContext(ctx).Table("rental_listings AS l").
|
|
Select(`l.id, l.listing_no, l.account_id, l.owner_id, l.price_cent AS listing_price_cent,
|
|
a.title AS account_title, a.server_region, a.login_platform, a.haf_coin_amount, a.asset_summary,
|
|
owner.phone AS owner_phone, l.handoff_mode, l.settlement_mode,
|
|
CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
|
|
COALESCE(lu.source_channel, '') AS source_channel`).
|
|
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
|
Joins("JOIN users AS owner ON owner.id = l.owner_id").
|
|
Joins("LEFT JOIN (?) AS latest_upload ON latest_upload.listing_id = l.id", latestUploads).
|
|
Joins("LEFT JOIN listing_uploads AS lu ON lu.id = latest_upload.upload_id").
|
|
Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false)
|
|
if kw := strings.TrimSpace(query.Keyword); kw != "" {
|
|
like := "%" + kw + "%"
|
|
db = db.Where("l.listing_no LIKE ? OR a.title LIKE ?", like, like)
|
|
}
|
|
switch strings.TrimSpace(query.SourceType) {
|
|
case ListingSourceExternal:
|
|
db = db.Where("lu.id IS NOT NULL")
|
|
case ListingSourceInternal:
|
|
db = db.Where("lu.id IS NULL")
|
|
}
|
|
|
|
var total int64
|
|
if err := db.Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
page, pageSize := normalizePaging(query.Page, query.PageSize)
|
|
var rows []availableListingRow
|
|
if err := db.Order("l.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]AvailableListingDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, row.toDTO())
|
|
}
|
|
return &AvailableListingsResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
|
return r.db.WithContext(ctx).Table("admin_pickups AS p").
|
|
Select(`p.*, l.listing_no, a.title AS account_title, a.server_region, a.login_platform, owner.phone AS owner_phone,
|
|
COALESCE((SELECT SUM(fa.profit_delta_cent) FROM admin_pickup_financial_adjustments AS fa WHERE fa.pickup_id = p.id), 0) AS profit_adjustment_cent,
|
|
COALESCE((SELECT SUM(fa.settle_delta_cent) FROM admin_pickup_financial_adjustments AS fa WHERE fa.pickup_id = p.id), 0) AS settle_adjustment_cent`).
|
|
Joins("JOIN rental_listings AS l ON l.id = p.listing_id").
|
|
Joins("JOIN game_accounts AS a ON a.id = p.account_id").
|
|
Joins("JOIN users AS owner ON owner.id = p.owner_id")
|
|
}
|
|
|
|
type pickupRow struct {
|
|
model.AdminPickup
|
|
ListingNo string
|
|
AccountTitle string
|
|
ServerRegion string
|
|
LoginPlatform string
|
|
OwnerPhone string
|
|
ProfitAdjustmentCent int64
|
|
SettleAdjustmentCent int64
|
|
}
|
|
|
|
func (row pickupRow) toDTO() PickupDTO {
|
|
return PickupDTO{
|
|
ID: row.ID,
|
|
PickupNo: row.PickupNo,
|
|
ListingID: row.ListingID,
|
|
ListingNo: row.ListingNo,
|
|
AccountID: row.AccountID,
|
|
AccountTitle: row.AccountTitle,
|
|
ServerRegion: row.ServerRegion,
|
|
LoginPlatform: row.LoginPlatform,
|
|
OwnerID: row.OwnerID,
|
|
OwnerPhone: row.OwnerPhone,
|
|
AdminID: row.AdminID,
|
|
Platform: row.Platform,
|
|
ShopName: row.ShopName,
|
|
AccountSource: row.AccountSource,
|
|
SourceChannel: row.SourceChannel,
|
|
SettlementMode: row.SettlementMode,
|
|
ListingPriceCent: row.ListingPriceCent,
|
|
OwnerPriceCent: row.OwnerPriceCent,
|
|
WebsiteProfitCent: row.WebsiteProfitCent,
|
|
ProfitAmountCent: row.ProfitAmountCent,
|
|
ProfitAdjustmentCent: row.ProfitAdjustmentCent,
|
|
EffectiveProfitAmountCent: row.ProfitAmountCent + row.ProfitAdjustmentCent,
|
|
SellerRatio: row.SellerRatio,
|
|
BuyerRatio: row.BuyerRatio,
|
|
AccountSnapshot: row.AccountSnapshot,
|
|
SettleAmountCent: row.SettleAmountCent,
|
|
SettleAdjustmentCent: row.SettleAdjustmentCent,
|
|
EffectiveSettleAmountCent: row.SettleAmountCent + row.SettleAdjustmentCent,
|
|
Status: row.Status,
|
|
OfflineSettlementStatus: row.OfflineSettlementStatus,
|
|
OfflineSettlementRemark: row.OfflineSettlementRemark,
|
|
OfflineSettledBy: row.OfflineSettledBy,
|
|
OfflineSettledAt: row.OfflineSettledAt,
|
|
Remark: row.Remark,
|
|
CompleteRemark: row.CompleteRemark,
|
|
CreatedAt: row.CreatedAt,
|
|
CompletedAt: row.CompletedAt,
|
|
CancelledAt: row.CancelledAt,
|
|
}
|
|
}
|
|
|
|
type pickupAdjustmentTotals struct {
|
|
ProfitAdjustmentCent int64
|
|
SettleAdjustmentCent int64
|
|
}
|
|
|
|
type pendingPickupAdjustmentTotals struct {
|
|
SettleAdjustmentCent int64
|
|
Count int64
|
|
}
|
|
|
|
func pickupFinancialAdjustmentTotals(db *gorm.DB, pickupID uint64) (pickupAdjustmentTotals, error) {
|
|
var totals pickupAdjustmentTotals
|
|
err := db.Model(&model.AdminPickupFinancialAdjustment{}).
|
|
Select(`COALESCE(SUM(profit_delta_cent), 0) AS profit_adjustment_cent,
|
|
COALESCE(SUM(settle_delta_cent), 0) AS settle_adjustment_cent`).
|
|
Where("pickup_id = ?", pickupID).
|
|
Scan(&totals).Error
|
|
return totals, err
|
|
}
|
|
|
|
func pendingPickupFinancialAdjustmentTotals(db *gorm.DB, pickupID uint64) (pendingPickupAdjustmentTotals, error) {
|
|
var totals pendingPickupAdjustmentTotals
|
|
err := db.Model(&model.AdminPickupFinancialAdjustment{}).
|
|
Select(`COALESCE(SUM(settle_delta_cent), 0) AS settle_adjustment_cent, COUNT(id) AS count`).
|
|
Where("pickup_id = ? AND status IN ?", pickupID, []string{FinancialAdjustmentStatusPending, FinancialAdjustmentStatusRecovery}).
|
|
Scan(&totals).Error
|
|
return totals, err
|
|
}
|
|
|
|
func pickupSettlementMode(listingMode, accountSource string) string {
|
|
if listingMode == SettlementModePlatformManaged || accountSource == AccountSourceExternalPlatformManaged {
|
|
return SettlementModePlatformManaged
|
|
}
|
|
return SettlementModeOwnerWallet
|
|
}
|
|
|
|
func pickupRequiresOfflineSettlement(pickup model.AdminPickup) bool {
|
|
return pickup.SettlementMode == SettlementModePlatformManaged
|
|
}
|
|
|
|
type availableListingRow struct {
|
|
ID uint64
|
|
ListingNo string
|
|
AccountID uint64
|
|
AccountTitle string
|
|
ServerRegion string
|
|
LoginPlatform string
|
|
OwnerID uint64
|
|
OwnerPhone string
|
|
ListingPriceCent int64
|
|
HafCoinAmount int64
|
|
AssetSummary datatypes.JSON
|
|
IsExternalUpload bool
|
|
SourceChannel string
|
|
HandoffMode string
|
|
SettlementMode string
|
|
}
|
|
|
|
func (row availableListingRow) toDTO() AvailableListingDTO {
|
|
snapshot := buildPickupPriceSnapshot(
|
|
model.RentalListing{PriceCent: row.ListingPriceCent},
|
|
model.GameAccount{HafCoinAmount: row.HafCoinAmount, AssetSummary: row.AssetSummary},
|
|
)
|
|
accountSource := AccountSourceInternal
|
|
if row.IsExternalUpload {
|
|
accountSource = AccountSourceExternalPlatformManaged
|
|
}
|
|
return AvailableListingDTO{
|
|
ID: row.ID,
|
|
ListingNo: row.ListingNo,
|
|
AccountID: row.AccountID,
|
|
AccountTitle: row.AccountTitle,
|
|
ServerRegion: row.ServerRegion,
|
|
LoginPlatform: row.LoginPlatform,
|
|
OwnerID: row.OwnerID,
|
|
OwnerPhone: row.OwnerPhone,
|
|
ListingPriceCent: snapshot.ListingPriceCent,
|
|
OwnerPriceCent: snapshot.OwnerPriceCent,
|
|
OwnerPurePriceCent: snapshot.OwnerPurePriceCent,
|
|
OwnerExtraItemPriceCent: snapshot.OwnerExtraItemPriceCent,
|
|
OwnerTotalPriceCent: snapshot.OwnerTotalPriceCent,
|
|
WebsiteProfitCent: snapshot.WebsiteProfitCent,
|
|
SellerRatio: snapshot.SellerRatio,
|
|
BuyerRatio: snapshot.BuyerRatio,
|
|
AccountSource: accountSource,
|
|
IsExternalUpload: row.IsExternalUpload,
|
|
SourceChannel: row.SourceChannel,
|
|
HandoffMode: row.HandoffMode,
|
|
SettlementMode: row.SettlementMode,
|
|
}
|
|
}
|
|
|
|
func resolvePickupAccountSource(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 errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return AccountSourceInternal, "", nil
|
|
}
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return AccountSourceExternalPlatformManaged, strings.TrimSpace(upload.SourceChannel), nil
|
|
}
|
|
|
|
func makePickupAccountSnapshot(account model.GameAccount, listing model.RentalListing) (datatypes.JSON, error) {
|
|
payload := map[string]any{
|
|
"listing_id": listing.ID,
|
|
"listing_no": listing.ListingNo,
|
|
"account_id": account.ID,
|
|
"title": account.Title,
|
|
"game_name": account.GameName,
|
|
"server_region": account.ServerRegion,
|
|
"login_platform": account.LoginPlatform,
|
|
"rank_level": account.RankLevel,
|
|
"haf_coin_amount": account.HafCoinAmount,
|
|
"asset_summary": account.AssetSummary,
|
|
"season_tags": account.SeasonTags,
|
|
"screenshot_urls": account.ScreenshotURLS,
|
|
"snapshot_version": 1,
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
return datatypes.JSON(raw), err
|
|
}
|
|
|
|
func (r *Repository) fallbackAccountSnapshot(ctx context.Context, listingID uint64, accountID uint64) (datatypes.JSON, error) {
|
|
var listing model.RentalListing
|
|
if err := r.db.WithContext(ctx).First(&listing, listingID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var account model.GameAccount
|
|
if err := r.db.WithContext(ctx).First(&account, accountID).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return makePickupAccountSnapshot(account, listing)
|
|
}
|
|
|
|
func isEmptyPickupAccountSnapshot(raw datatypes.JSON) bool {
|
|
trimmed := strings.TrimSpace(string(raw))
|
|
return trimmed == "" || trimmed == "null" || trimmed == "{}"
|
|
}
|
|
|
|
type pickupPriceSnapshot struct {
|
|
ListingPriceCent int64
|
|
OwnerPriceCent int64
|
|
OwnerPurePriceCent int64
|
|
OwnerExtraItemPriceCent int64
|
|
OwnerTotalPriceCent int64
|
|
WebsiteProfitCent int64
|
|
SellerRatio float64
|
|
BuyerRatio float64
|
|
}
|
|
|
|
func buildPickupPriceSnapshot(listing model.RentalListing, account model.GameAccount) pickupPriceSnapshot {
|
|
summary := decodePickupAssetSummary(account.AssetSummary)
|
|
breakdown := pickupPriceBreakdown(summary)
|
|
listingPriceCent := maxPickupCent(listing.PriceCent, 0)
|
|
ownerPurePriceCent := yuanToPickupCent(readPickupNumber(breakdown["seller_coin_base_price"]))
|
|
ownerExtraItemPriceCent := yuanToPickupCent(readPickupNumber(breakdown["consumable_price"]))
|
|
ownerTotalPriceCent := yuanToPickupCent(readPickupNumber(breakdown["seller_total_price"]))
|
|
if ownerTotalPriceCent <= 0 && (ownerPurePriceCent > 0 || ownerExtraItemPriceCent > 0) {
|
|
ownerTotalPriceCent = ownerPurePriceCent + ownerExtraItemPriceCent
|
|
}
|
|
if ownerTotalPriceCent <= 0 || (listingPriceCent > 0 && ownerTotalPriceCent > listingPriceCent) {
|
|
ownerTotalPriceCent = listingPriceCent
|
|
}
|
|
ownerPurePriceCent, ownerExtraItemPriceCent = normalizePickupOwnerPriceSplit(
|
|
ownerTotalPriceCent,
|
|
ownerPurePriceCent,
|
|
ownerExtraItemPriceCent,
|
|
)
|
|
websiteProfitCent := yuanToPickupCent(readPickupNumber(breakdown["platform_markup_amount"]))
|
|
if websiteProfitCent <= 0 {
|
|
websiteProfitCent = listingPriceCent - ownerTotalPriceCent
|
|
}
|
|
if websiteProfitCent < 0 {
|
|
websiteProfitCent = 0
|
|
}
|
|
sellerRatio := readPickupNumber(breakdown["seller_ratio"])
|
|
if sellerRatio <= 0 {
|
|
sellerRatio = ratioFromCoin(account.HafCoinAmount, readPickupNumber(breakdown["seller_coin_base_price"]))
|
|
}
|
|
buyerRatio := readPickupNumber(breakdown["buyer_ratio"])
|
|
if buyerRatio <= 0 {
|
|
buyerRatio = readPickupNumber(summary["publish_ratio"])
|
|
}
|
|
if buyerRatio <= 0 {
|
|
buyerRatio = ratioFromCoin(account.HafCoinAmount, readPickupNumber(breakdown["buyer_coin_base_price"]))
|
|
}
|
|
return pickupPriceSnapshot{
|
|
ListingPriceCent: listingPriceCent,
|
|
OwnerPriceCent: ownerTotalPriceCent,
|
|
OwnerPurePriceCent: ownerPurePriceCent,
|
|
OwnerExtraItemPriceCent: ownerExtraItemPriceCent,
|
|
OwnerTotalPriceCent: ownerTotalPriceCent,
|
|
WebsiteProfitCent: websiteProfitCent,
|
|
SellerRatio: roundPickupRatio(sellerRatio),
|
|
BuyerRatio: roundPickupRatio(buyerRatio),
|
|
}
|
|
}
|
|
|
|
func normalizePickupOwnerPriceSplit(totalCent, pureCent, extraCent int64) (int64, int64) {
|
|
if totalCent <= 0 {
|
|
return 0, 0
|
|
}
|
|
pureCent = maxPickupCent(pureCent, 0)
|
|
extraCent = maxPickupCent(extraCent, 0)
|
|
switch {
|
|
case pureCent == 0 && extraCent == 0:
|
|
pureCent = totalCent
|
|
case pureCent == 0:
|
|
pureCent = maxPickupCent(totalCent-extraCent, 0)
|
|
case extraCent == 0:
|
|
extraCent = maxPickupCent(totalCent-pureCent, 0)
|
|
case pureCent+extraCent != totalCent:
|
|
if pureCent > totalCent {
|
|
pureCent = totalCent
|
|
extraCent = 0
|
|
} else {
|
|
extraCent = totalCent - pureCent
|
|
}
|
|
}
|
|
return pureCent, extraCent
|
|
}
|
|
|
|
func decodePickupAssetSummary(raw datatypes.JSON) map[string]any {
|
|
if len(raw) == 0 {
|
|
return map[string]any{}
|
|
}
|
|
var summary map[string]any
|
|
if err := json.Unmarshal(raw, &summary); err != nil {
|
|
return map[string]any{}
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func pickupPriceBreakdown(summary map[string]any) map[string]any {
|
|
if summary == nil {
|
|
return map[string]any{}
|
|
}
|
|
if breakdown, ok := summary["price_breakdown"].(map[string]any); ok {
|
|
return breakdown
|
|
}
|
|
if raw, ok := summary["price_breakdown"].(string); ok && strings.TrimSpace(raw) != "" {
|
|
var breakdown map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &breakdown); err == nil {
|
|
return breakdown
|
|
}
|
|
}
|
|
return map[string]any{}
|
|
}
|
|
|
|
func readPickupNumber(value any) float64 {
|
|
switch typed := value.(type) {
|
|
case float64:
|
|
return typed
|
|
case float32:
|
|
return float64(typed)
|
|
case int:
|
|
return float64(typed)
|
|
case int64:
|
|
return float64(typed)
|
|
case json.Number:
|
|
number, err := typed.Float64()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return number
|
|
case string:
|
|
number, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return number
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func yuanToPickupCent(value float64) int64 {
|
|
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
|
return 0
|
|
}
|
|
return int64(math.Round(value * 100))
|
|
}
|
|
|
|
func ratioFromCoin(coinAmount int64, basePrice float64) float64 {
|
|
if coinAmount <= 0 || basePrice <= 0 {
|
|
return 0
|
|
}
|
|
return float64(coinAmount) / 10000 / basePrice
|
|
}
|
|
|
|
func roundPickupRatio(value float64) float64 {
|
|
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
|
return 0
|
|
}
|
|
return math.Round(value*100) / 100
|
|
}
|
|
|
|
func maxPickupCent(a, b int64) int64 {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func normalizePaging(page, pageSize int) (int, int) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if pageSize < 1 {
|
|
pageSize = 20
|
|
}
|
|
if pageSize > 100 {
|
|
pageSize = 100
|
|
}
|
|
return page, pageSize
|
|
}
|
|
|
|
// newPickupNo 生成提号编号:PK + YYYYMMDDHHMMSS + 3位随机。
|
|
func newPickupNo() (string, error) {
|
|
now := timeutil.ShanghaiNow()
|
|
timeStr := now.Format("20060102150405")
|
|
buf := make([]byte, 2)
|
|
if _, err := crand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
randomNum := (int(buf[0])<<8 | int(buf[1])) % 1000
|
|
return "PK" + timeStr + strconv.Itoa(1000 + randomNum)[1:], nil
|
|
}
|