- 新建 admin_pickups 表独立于 rental_orders,不污染订单状态机与财务统计口径 - 提号中/已完成/已取消三态:创建锁定账号,完成时给卖家钱包加可用余额并下架 listing,取消解锁账号 - 完成时 listing 置 completed,复用现有 listingLockedForOwnerMutation 拦截再上架 - 管理端提号管理页(列表/创建/完成/取消/可提号账号搜索)+ 卖家端提号记录页 - 财务仪表盘新增线下提号独立统计块,与正常订单口径分离 - 钱包流水 label、状态标签、菜单入口同步补齐 - 优化卖家服务悬浮菜单为一行四个,尺寸与配色对齐买家服务
416 lines
12 KiB
Go
416 lines
12 KiB
Go
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
|
||
}
|