package pickup import ( "context" crand "crypto/rand" "errors" "fmt" "strconv" "strings" "time" "hfb_sys/backend/internal/auditlog" "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/gorm" "gorm.io/gorm/clause" ) type Repository struct { db *gorm.DB } func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } // Create 创建提号订单(状态:提号中)。 // 锁定 listing.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.First(&account, listing.AccountID).Error; 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), Status: StatusPickingUp, Remark: strings.TrimSpace(req.Remark), } if err := tx.Create(&pickup).Error; err != nil { return err } createdID = pickup.ID listing.InTransaction = true if err := tx.Save(&listing).Error; 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, }, }); err != nil { return err } return nil }) if err != nil { return nil, err } return r.FindByID(ctx, createdID) } // Complete 完成提号:给卖家加可用余额,listing 置 completed(售出终态,不可再上架)。 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 } 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 _ = tx.First(&account, pickup.AccountID).Error now := time.Now() pickup.SettleAmountCent = req.SettleAmountCent pickup.Status = StatusCompleted pickup.CompleteRemark = strings.TrimSpace(req.CompleteRemark) pickup.CompletedAt = &now if err := tx.Save(&pickup).Error; err != nil { return err } // listing 置 completed:listingLockedForOwnerMutation 已拦截此状态再上架。 listing.InTransaction = true listing.Status = "completed" if err := tx.Save(&listing).Error; err != nil { return err } 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 } bid := pickup.ID 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 := 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, }, }); err != nil { return err } return nil }) if err != nil { return nil, err } return r.FindByID(ctx, pickupID) } // Cancel 取消提号:解锁 listing.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 } var listing model.RentalListing if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, pickup.ListingID).Error; err != nil { return err } now := time.Now() pickup.Status = StatusCancelled pickup.CancelledAt = &now if err := tx.Save(&pickup).Error; err != nil { return err } listing.InTransaction = false if err := tx.Save(&listing).Error; 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() 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 kw := strings.TrimSpace(query.Keyword); kw != "" { like := "%" + kw + "%" db = db.Where("p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ?", like, like, like) } return r.paginatePickups(db, query.Page, query.PageSize) } 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) } func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int) (*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 { items = append(items, row.toDTO()) } return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } // ListAvailableListings 可提号的 listing:已发布、已审核、未在交易中。 func (r *Repository) ListAvailableListings(ctx context.Context, keyword string, page, pageSize int) (*AvailableListingsResult, error) { if r == nil || r.db == nil { return nil, ErrDependencyUnavailable } db := r.db.WithContext(ctx).Table("rental_listings AS l"). Select("l.id, l.listing_no, l.account_id, a.title AS account_title, a.server_region, a.login_platform, l.owner_id, owner.phone AS owner_phone"). Joins("JOIN game_accounts AS a ON a.id = l.account_id"). Joins("JOIN users AS owner ON owner.id = l.owner_id"). Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false) if kw := strings.TrimSpace(keyword); kw != "" { like := "%" + kw + "%" db = db.Where("l.listing_no LIKE ? OR a.title LIKE ?", like, like) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, err } page, pageSize = normalizePaging(page, pageSize) var rows []AvailableListingDTO if err := db.Order("l.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil { return nil, err } return &AvailableListingsResult{Items: rows, 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"). 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 } 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, SettleAmountCent: row.SettleAmountCent, Status: row.Status, Remark: row.Remark, CompleteRemark: row.CompleteRemark, CreatedAt: row.CreatedAt, CompletedAt: row.CompletedAt, CancelledAt: row.CancelledAt, } } 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 }