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/invitation" "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 } shopName := strings.TrimSpace(req.ShopName) rawShopName := shopName if shopName != "" { var mapping model.PickupShopMapping if err := tx.Where("source_name = ?", shopName).First(&mapping).Error; err == nil && mapping.TargetShopName != "" { shopName = mapping.TargetShopName } } pickup := model.AdminPickup{ PickupNo: pickupNo, ListingID: listing.ID, AccountID: listing.AccountID, OwnerID: listing.OwnerID, AdminID: adminID, Operator: strings.TrimSpace(req.Operator), Platform: strings.TrimSpace(req.Platform), ThirdPartyOrderNo: strings.TrimSpace(req.ThirdPartyOrderNo), ShopName: shopName, RawShopName: rawShopName, AccountSource: accountSource, SourceChannel: sourceChannel, SettlementMode: pickupSettlementMode(listing.SettlementMode, accountSource), ListingPriceCent: priceSnapshot.ListingPriceCent, OrderAmountCent: req.OrderAmountCent, DepositAmountCent: req.DepositAmountCent, 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, "order_amount_cent": pickup.OrderAmountCent, "deposit_amount_cent": pickup.DepositAmountCent, "profit_amount_cent": pickup.ProfitAmountCent, "account_source": pickup.AccountSource, "source_channel": pickup.SourceChannel, "settlement_mode": pickup.SettlementMode, "operator": pickup.Operator, "third_party_order_no": pickup.ThirdPartyOrderNo, }, 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, "third_party_order_no": pickup.ThirdPartyOrderNo, "shop_name": pickup.ShopName, "operator": pickup.Operator, "account_source": pickup.AccountSource, "source_channel": pickup.SourceChannel, "order_amount_cent": pickup.OrderAmountCent, "deposit_amount_cent": pickup.DepositAmountCent, "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 grossBasisCent := pickup.ProfitAmountCent; grossBasisCent > 0 { sellerSiteID := uint64(0) if listing.DistributionSiteID != nil { sellerSiteID = *listing.DistributionSiteID } if _, err := invitation.AwardSellerSideRebateTx(tx, invitation.SellerSideRebateInput{ Source: invitation.RebateSourcePickup, SourceID: pickup.ID, SourceNo: pickup.PickupNo, SellerID: pickup.OwnerID, GrossBasisCent: grossBasisCent, BasisCent: grossBasisCent / 2, SiteID: sellerSiteID, RateReferenceAt: now, SettledAt: now, RemarkPrefix: "邀请卖家提号返利", }); 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) } // UpdateThirdPartyOrderNo 补录或修改提号单第三方单号。 func (r *Repository) UpdateThirdPartyOrderNo(ctx context.Context, pickupID uint64, req UpdateThirdPartyOrderNoRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } newThirdPartyOrderNo := strings.TrimSpace(req.ThirdPartyOrderNo) if newThirdPartyOrderNo == "" || len([]rune(newThirdPartyOrderNo)) > 128 { return nil, ErrInvalidThirdPartyOrderNo } 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 == StatusCancelled { return ErrPickupCancelled } before := pickupState(pickup) oldThirdPartyOrderNo := pickup.ThirdPartyOrderNo pickup.ThirdPartyOrderNo = newThirdPartyOrderNo if err := tx.Model(&pickup).Update("third_party_order_no", pickup.ThirdPartyOrderNo).Error; err != nil { return err } if err := appendPickupEvent(tx, pickup, "pickup_third_party_order_no_updated", adminID, "补录/修改第三方单号。", "", map[string]any{ "old_third_party_order_no": oldThirdPartyOrderNo, "third_party_order_no": pickup.ThirdPartyOrderNo, }, before); err != nil { return err } bid := pickup.ID return auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", ActorID: adminID, Action: "pickup_third_party_order_no_update", BizType: "admin_pickup", BizID: &bid, Meta: meta, Detail: map[string]any{ "pickup_no": pickup.PickupNo, "old_third_party_order_no": oldThirdPartyOrderNo, "new_third_party_order_no": pickup.ThirdPartyOrderNo, }, }) }) 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 applyAdminPickupFilters(db *gorm.DB, query AdminPickupQuery) *gorm.DB { 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 operator := strings.TrimSpace(query.Operator); operator != "" { db = db.Where("p.operator = ?", operator) } if thirdPartyOrderNo := strings.TrimSpace(query.ThirdPartyOrderNo); thirdPartyOrderNo != "" { db = db.Where("p.third_party_order_no = ?", thirdPartyOrderNo) } if query.StartTime != nil { db = db.Where("p.created_at >= ?", *query.StartTime) } if query.EndTime != nil { db = db.Where("p.created_at <= ?", *query.EndTime) } if kw := strings.TrimSpace(query.Keyword); kw != "" { upper := strings.ToUpper(kw) if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) { // 11 位手机号:命中 users(phone) 唯一索引 db = db.Where("owner.phone = ?", kw) } else if strings.HasPrefix(upper, "PK") || strings.HasPrefix(upper, "P") { // 提号编号:命中 admin_pickups(pickup_no) 唯一索引 db = db.Where("p.pickup_no = ?", kw) } else if strings.HasPrefix(upper, "L") || (len(kw) >= 12 && isAllDigits(kw)) { // 商品编号(如 L前缀或时间计算生成的 12位纯数字如 202609040184):命中 rental_listings(listing_no) 唯一索引 db = db.Where("l.listing_no = ?", kw) } else if id, err := strconv.ParseUint(kw, 10, 64); err == nil && id > 0 && kw[0] != '0' && id < 100000000 { // 短纯数字 ID(非零开头):命中 admin_pickups 主键及关联 ID db = db.Where("(p.id = ? OR p.listing_id = ? OR p.account_id = ? OR p.owner_id = ?)", id, id, id, id) } else { like := "%" + kw + "%" db = db.Where( "p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ? OR owner.phone LIKE ? OR p.third_party_order_no LIKE ? OR p.operator LIKE ?", like, like, like, like, like, like, ) } } return db } func (r *Repository) adminPickupCountQuery(ctx context.Context, query AdminPickupQuery) *gorm.DB { kw := strings.TrimSpace(query.Keyword) if kw == "" { return r.db.WithContext(ctx).Table("admin_pickups AS p") } upper := strings.ToUpper(kw) if strings.HasPrefix(upper, "PK") || strings.HasPrefix(upper, "P") { return r.db.WithContext(ctx).Table("admin_pickups AS p") } if id, err := strconv.ParseUint(kw, 10, 64); err == nil && id > 0 && kw[0] != '0' && id < 100000000 { return r.db.WithContext(ctx).Table("admin_pickups AS p") } if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) { return r.db.WithContext(ctx).Table("admin_pickups AS p"). Joins("JOIN users AS owner ON owner.id = p.owner_id") } if strings.HasPrefix(upper, "L") || (len(kw) >= 12 && isAllDigits(kw)) { return r.db.WithContext(ctx).Table("admin_pickups AS p"). Joins("JOIN rental_listings AS l ON l.id = p.listing_id") } return r.db.WithContext(ctx).Table("admin_pickups AS p"). 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") } func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*PaginatedResult, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } page, pageSize := normalizePaging(query.Page, query.PageSize) offset := (page - 1) * pageSize var rows []pickupRow db := applyAdminPickupFilters(r.baseQuery(ctx), query) if err := db.Order("p.id DESC").Offset(offset).Limit(pageSize).Scan(&rows).Error; err != nil { return nil, err } var total int64 // 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,彻底免除第二次 COUNT(*) 扫描 if page == 1 && len(rows) < pageSize { total = int64(len(rows)) } else { countDB := applyAdminPickupFilters(r.adminPickupCountQuery(ctx, query), query) if err := countDB.Count(&total).Error; err != nil { return nil, err } } items := make([]PickupDTO, 0, len(rows)) for _, row := range rows { item := row.toDTO() item.AccountSnapshot = nil items = append(items, item) } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } // ListShopNames 返回店铺配置与历史提号中已使用的店铺名称,供后台选择和筛选。 // 优先返回 pickup_shops 中配置的 active 店铺(按权重排序),再补充历史提号中未在配置表但已有的店铺。 func (r *Repository) ListShopNames(ctx context.Context, platform string) ([]string, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } result := make([]string, 0) seen := make(map[string]struct{}) // 1. 查询店铺配置表中的 active 店铺 shopDB := r.db.WithContext(ctx).Model(&model.PickupShop{}).Where("status = ?", PickupShopStatusActive) if val := strings.TrimSpace(platform); val != "" { shopDB = shopDB.Where("platform = ? OR platform = ''", val) } var configuredNames []string if err := shopDB.Order("sort_order DESC, name ASC").Pluck("name", &configuredNames).Error; err == nil { for _, name := range configuredNames { trimmed := strings.TrimSpace(name) if trimmed != "" { if _, ok := seen[trimmed]; !ok { seen[trimmed] = struct{}{} result = append(result, trimmed) } } } } // 2. 查询 admin_pickups 中的历史店铺作为补充 historyDB := r.db.WithContext(ctx).Model(&model.AdminPickup{}).Where("shop_name <> ''") if val := strings.TrimSpace(platform); val != "" { historyDB = historyDB.Where("platform = ?", val) } var historyNames []string if err := historyDB.Distinct("shop_name").Order("shop_name ASC").Limit(200).Pluck("shop_name", &historyNames).Error; err != nil { return nil, err } for _, name := range historyNames { trimmed := strings.TrimSpace(name) if trimmed != "" { if _, ok := seen[trimmed]; !ok { seen[trimmed] = struct{}{} result = append(result, trimmed) } } } return result, nil } // ListShops 查询店铺配置列表 func (r *Repository) ListShops(ctx context.Context, query PickupShopQuery) ([]PickupShopDTO, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } db := r.db.WithContext(ctx).Model(&model.PickupShop{}) if kw := strings.TrimSpace(query.Keyword); kw != "" { like := "%" + kw + "%" db = db.Where("name LIKE ? OR remark LIKE ?", like, like) } if p := strings.TrimSpace(query.Platform); p != "" { db = db.Where("platform = ?", p) } if s := strings.TrimSpace(query.Status); s != "" { db = db.Where("status = ?", s) } var rows []model.PickupShop if err := db.Order("sort_order DESC, id DESC").Find(&rows).Error; err != nil { return nil, err } items := make([]PickupShopDTO, 0, len(rows)) for _, row := range rows { items = append(items, PickupShopDTO{ ID: row.ID, Name: row.Name, Platform: row.Platform, Status: row.Status, SortOrder: row.SortOrder, Remark: row.Remark, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, }) } return items, nil } // CreateShop 创建店铺配置 func (r *Repository) CreateShop(ctx context.Context, req CreatePickupShopRequest, adminID uint64, meta auditlog.Meta) (*PickupShopDTO, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } name := strings.TrimSpace(req.Name) if name == "" { return nil, ErrInvalidAmount } var count int64 if err := r.db.WithContext(ctx).Model(&model.PickupShop{}).Where("name = ?", name).Count(&count).Error; err != nil { return nil, err } if count > 0 { return nil, ErrPickupShopNameExists } status := strings.TrimSpace(req.Status) if status == "" { status = PickupShopStatusActive } now := time.Now() shop := model.PickupShop{ Name: name, Platform: strings.TrimSpace(req.Platform), Status: status, SortOrder: req.SortOrder, Remark: strings.TrimSpace(req.Remark), CreatedAt: now, UpdatedAt: now, } if err := r.db.WithContext(ctx).Create(&shop).Error; err != nil { return nil, err } bid := shop.ID _ = auditlog.Append(r.db.WithContext(ctx), auditlog.Entry{ ActorType: "admin", ActorID: adminID, Action: "pickup_shop_create", BizType: "pickup_shop", BizID: &bid, Meta: meta, Detail: map[string]any{ "shop_id": shop.ID, "name": shop.Name, "platform": shop.Platform, "status": shop.Status, }, }) return &PickupShopDTO{ ID: shop.ID, Name: shop.Name, Platform: shop.Platform, Status: shop.Status, SortOrder: shop.SortOrder, Remark: shop.Remark, CreatedAt: shop.CreatedAt, UpdatedAt: shop.UpdatedAt, }, nil } // UpdateShop 更新店铺配置 func (r *Repository) UpdateShop(ctx context.Context, id uint64, req UpdatePickupShopRequest, adminID uint64, meta auditlog.Meta) (*PickupShopDTO, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } name := strings.TrimSpace(req.Name) if name == "" { return nil, ErrInvalidAmount } var shop model.PickupShop if err := r.db.WithContext(ctx).First(&shop, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrPickupShopNotFound } return nil, err } var count int64 if err := r.db.WithContext(ctx).Model(&model.PickupShop{}).Where("name = ? AND id <> ?", name, id).Count(&count).Error; err != nil { return nil, err } if count > 0 { return nil, ErrPickupShopNameExists } shop.Name = name shop.Platform = strings.TrimSpace(req.Platform) shop.Status = strings.TrimSpace(req.Status) shop.SortOrder = req.SortOrder shop.Remark = strings.TrimSpace(req.Remark) shop.UpdatedAt = time.Now() if err := r.db.WithContext(ctx).Save(&shop).Error; err != nil { return nil, err } bid := shop.ID _ = auditlog.Append(r.db.WithContext(ctx), auditlog.Entry{ ActorType: "admin", ActorID: adminID, Action: "pickup_shop_update", BizType: "pickup_shop", BizID: &bid, Meta: meta, Detail: map[string]any{ "shop_id": shop.ID, "name": shop.Name, "platform": shop.Platform, "status": shop.Status, }, }) return &PickupShopDTO{ ID: shop.ID, Name: shop.Name, Platform: shop.Platform, Status: shop.Status, SortOrder: shop.SortOrder, Remark: shop.Remark, CreatedAt: shop.CreatedAt, UpdatedAt: shop.UpdatedAt, }, nil } // DeleteShop 删除店铺配置 func (r *Repository) DeleteShop(ctx context.Context, id uint64, adminID uint64, meta auditlog.Meta) error { if r == nil || r.db == nil { return ErrDependencyUnavailable } var shop model.PickupShop if err := r.db.WithContext(ctx).First(&shop, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrPickupShopNotFound } return err } if err := r.db.WithContext(ctx).Delete(&shop).Error; err != nil { return err } bid := shop.ID _ = auditlog.Append(r.db.WithContext(ctx), auditlog.Entry{ ActorType: "admin", ActorID: adminID, Action: "pickup_shop_delete", BizType: "pickup_shop", BizID: &bid, Meta: meta, Detail: map[string]any{ "shop_id": shop.ID, "name": shop.Name, }, }) return nil } // GetHistoricalShopStats 聚合统计 admin_pickups 中的全部历史店铺,并对照标准库与映射规则 func (r *Repository) GetHistoricalShopStats(ctx context.Context) (*PickupShopHistoricalStatsDTO, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } type rawCountRow struct { SourceName string `gorm:"column:source_name"` Cnt int64 `gorm:"column:cnt"` } var rows []rawCountRow err := r.db.WithContext(ctx).Model(&model.AdminPickup{}). Select("CASE WHEN raw_shop_name <> '' THEN raw_shop_name ELSE shop_name END AS source_name, COUNT(*) AS cnt"). Group("source_name"). Order("cnt DESC, source_name ASC"). Scan(&rows).Error if err != nil { return nil, err } var standardShops []model.PickupShop if err := r.db.WithContext(ctx).Model(&model.PickupShop{}).Find(&standardShops).Error; err != nil { return nil, err } standardMap := make(map[string]model.PickupShop) for _, s := range standardShops { standardMap[s.Name] = s } var mappings []model.PickupShopMapping if err := r.db.WithContext(ctx).Model(&model.PickupShopMapping{}).Find(&mappings).Error; err != nil { return nil, err } mappingMap := make(map[string]model.PickupShopMapping) for _, m := range mappings { mappingMap[m.SourceName] = m } var totalPickups int64 standardCount := 0 mappedCount := 0 unmappedCount := 0 seenSource := make(map[string]bool) items := make([]PickupShopHistoricalItemDTO, 0, len(rows)+len(mappings)) for _, row := range rows { seenSource[row.SourceName] = true totalPickups += row.Cnt item := PickupShopHistoricalItemDTO{ SourceName: row.SourceName, Count: row.Cnt, } if mapping, ok := mappingMap[row.SourceName]; ok { item.MappingStatus = "mapped" item.TargetShopID = mapping.TargetShopID item.TargetShopName = mapping.TargetShopName item.Remark = mapping.Remark mappedCount++ } else if std, ok := standardMap[row.SourceName]; ok && row.SourceName != "" { item.MappingStatus = "standard" item.TargetShopID = std.ID item.TargetShopName = std.Name standardCount++ } else { item.MappingStatus = "unmapped" unmappedCount++ } items = append(items, item) } for _, m := range mappings { if !seenSource[m.SourceName] { mappedCount++ items = append(items, PickupShopHistoricalItemDTO{ SourceName: m.SourceName, Count: m.MappedCount, MappingStatus: "mapped", TargetShopID: m.TargetShopID, TargetShopName: m.TargetShopName, Remark: m.Remark, }) } } return &PickupShopHistoricalStatsDTO{ Items: items, TotalPickups: totalPickups, TotalShops: len(items), StandardCount: standardCount, MappedCount: mappedCount, UnmappedCount: unmappedCount, }, nil } // MapHistoricalShops 批量将历史店铺名称映射至目标标准店铺,并同步清洗历史提号单中的店铺名 func (r *Repository) MapHistoricalShops(ctx context.Context, req MapHistoricalShopsRequest, adminID uint64, meta auditlog.Meta) (*MapHistoricalShopsResultDTO, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } targetName := strings.TrimSpace(req.TargetShopName) if targetName == "" { return nil, ErrInvalidAmount } fromNames := make([]string, 0, len(req.FromShopNames)) seen := make(map[string]bool) for _, name := range req.FromShopNames { if seen[name] { continue } seen[name] = true fromNames = append(fromNames, name) } if len(fromNames) == 0 { return nil, ErrInvalidAmount } var result MapHistoricalShopsResultDTO result.TargetShopName = targetName err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var targetShop model.PickupShop findErr := tx.Where("name = ?", targetName).First(&targetShop).Error if errors.Is(findErr, gorm.ErrRecordNotFound) { if !req.CreateTargetMissing { return ErrPickupShopNotFound } now := time.Now() targetShop = model.PickupShop{ Name: targetName, Platform: strings.TrimSpace(req.TargetPlatform), Status: PickupShopStatusActive, SortOrder: 0, Remark: "历史映射自动创建", CreatedAt: now, UpdatedAt: now, } if err := tx.Create(&targetShop).Error; err != nil { return err } result.CreatedStandardShop = true } else if findErr != nil { return findErr } result.TargetShopID = targetShop.ID // 将未初始化 raw_shop_name 的记录填入旧 shop_name if err := tx.Exec(`UPDATE admin_pickups SET raw_shop_name = shop_name WHERE (raw_shop_name = '' OR raw_shop_name IS NULL) AND (shop_name IN ? OR (shop_name = '' AND '' IN ?))`, fromNames, fromNames).Error; err != nil { return err } // 批量更新 admin_pickups 的 shop_name res := tx.Model(&model.AdminPickup{}). Where("raw_shop_name IN ?", fromNames). Update("shop_name", targetName) if res.Error != nil { return res.Error } result.UpdatedPickupsCount = res.RowsAffected now := time.Now() for _, fromName := range fromNames { if fromName == targetName { continue } var mappedCount int64 tx.Model(&model.AdminPickup{}).Where("raw_shop_name = ?", fromName).Count(&mappedCount) var existing model.PickupShopMapping if err := tx.Where("source_name = ?", fromName).First(&existing).Error; errors.Is(err, gorm.ErrRecordNotFound) { newMapping := model.PickupShopMapping{ SourceName: fromName, TargetShopID: targetShop.ID, TargetShopName: targetName, MappedCount: mappedCount, Remark: strings.TrimSpace(req.Remark), CreatedAt: now, UpdatedAt: now, } if err := tx.Create(&newMapping).Error; err != nil { return err } } else if err == nil { existing.TargetShopID = targetShop.ID existing.TargetShopName = targetName existing.MappedCount = mappedCount if req.Remark != "" { existing.Remark = strings.TrimSpace(req.Remark) } existing.UpdatedAt = now if err := tx.Save(&existing).Error; err != nil { return err } } else { return err } } bid := targetShop.ID _ = auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", ActorID: adminID, Action: "pickup_shop_map_historical", BizType: "pickup_shop", BizID: &bid, Meta: meta, Detail: map[string]any{ "target_shop_id": targetShop.ID, "target_shop_name": targetName, "from_shop_names": fromNames, "updated_count": result.UpdatedPickupsCount, }, }) return nil }) if err != nil { return nil, err } return &result, nil } // DeleteShopMapping 删除指定历史店铺的映射规则,并还原历史提号单中的原始店铺名 func (r *Repository) DeleteShopMapping(ctx context.Context, sourceName 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 mapping model.PickupShopMapping if err := tx.Where("source_name = ?", sourceName).First(&mapping).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil } return err } if err := tx.Model(&model.AdminPickup{}). Where("raw_shop_name = ?", sourceName). Update("shop_name", sourceName).Error; err != nil { return err } if err := tx.Delete(&mapping).Error; err != nil { return err } bid := mapping.ID _ = auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", ActorID: adminID, Action: "pickup_shop_unmap_historical", BizType: "pickup_shop", BizID: &bid, Meta: meta, Detail: map[string]any{ "source_name": sourceName, "target_shop_name": mapping.TargetShopName, }, }) return 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 rows []pickupRow if err := db.Order("p.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil { return nil, err } var total int64 if page == 1 && len(rows) < pageSize { total = int64(len(rows)) } else { // 使用独立干净的 Count 查询,避免触发 baseQuery 中的关联子查询 countDB := db.Session(&gorm.Session{}).Select("p.id") if err := countDB.Count(&total).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") buildQuery := func() *gorm.DB { q := r.db.WithContext(ctx).Table("rental_listings AS l"). 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 != "" { upper := strings.ToUpper(kw) if strings.HasPrefix(upper, "L") || (len(kw) >= 12 && isAllDigits(kw)) { q = q.Where("l.listing_no = ?", kw) } else { like := "%" + kw + "%" q = q.Where("l.listing_no LIKE ? OR a.title LIKE ?", like, like) } } switch strings.TrimSpace(query.SourceType) { case ListingSourceExternal: q = q.Where("lu.id IS NOT NULL") case ListingSourceInternal: q = q.Where("lu.id IS NULL") } return q } page, pageSize := normalizePaging(query.Page, query.PageSize) var rows []availableListingRow if err := buildQuery().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`). Order("l.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil { return nil, err } var total int64 if page == 1 && len(rows) < pageSize { total = int64(len(rows)) } else { if err := buildQuery().Count(&total).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, Operator: row.Operator, Platform: row.Platform, ThirdPartyOrderNo: row.ThirdPartyOrderNo, ShopName: row.ShopName, RawShopName: row.RawShopName, AccountSource: row.AccountSource, SourceChannel: row.SourceChannel, SettlementMode: row.SettlementMode, ListingPriceCent: row.ListingPriceCent, OrderAmountCent: row.OrderAmountCent, DepositAmountCent: row.DepositAmountCent, 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 } func isAllDigits(s string) bool { if s == "" { return false } for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return false } } return true }