Files
hfb_sys/backend/internal/modules/pickup/repository.go
T
yml2213 9ef0843eb0 feat(pickup): 新增管理员线下提号功能
- 新建 admin_pickups 表独立于 rental_orders,不污染订单状态机与财务统计口径
- 提号中/已完成/已取消三态:创建锁定账号,完成时给卖家钱包加可用余额并下架 listing,取消解锁账号
- 完成时 listing 置 completed,复用现有 listingLockedForOwnerMutation 拦截再上架
- 管理端提号管理页(列表/创建/完成/取消/可提号账号搜索)+ 卖家端提号记录页
- 财务仪表盘新增线下提号独立统计块,与正常订单口径分离
- 钱包流水 label、状态标签、菜单入口同步补齐
- 优化卖家服务悬浮菜单为一行四个,尺寸与配色对齐买家服务
2026-07-03 19:04:35 +08:00

416 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 置 completedlistingLockedForOwnerMutation 已拦截此状态再上架。
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
}