Files
hfb_sys/backend/internal/modules/pickup/repository.go
T
2026-07-08 00:16:07 +08:00

657 lines
20 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"
"encoding/json"
"errors"
"fmt"
"math"
"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/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.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
}
priceSnapshot := buildPickupPriceSnapshot(listing, account)
accountSnapshot, err := makePickupAccountSnapshot(account, listing)
if err != nil {
return err
}
pickupNo, err := newPickupNo()
if err != nil {
return err
}
pickup := model.AdminPickup{
PickupNo: pickupNo,
ListingID: listing.ID,
AccountID: listing.AccountID,
OwnerID: listing.OwnerID,
AdminID: adminID,
Platform: strings.TrimSpace(req.Platform),
ListingPriceCent: priceSnapshot.ListingPriceCent,
OwnerPriceCent: priceSnapshot.OwnerPriceCent,
WebsiteProfitCent: priceSnapshot.WebsiteProfitCent,
ProfitAmountCent: req.ProfitAmountCent,
SellerRatio: priceSnapshot.SellerRatio,
BuyerRatio: priceSnapshot.BuyerRatio,
AccountSnapshot: accountSnapshot,
Status: StatusPickingUp,
Remark: strings.TrimSpace(req.Remark),
}
if err := tx.Create(&pickup).Error; err != nil {
return err
}
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,
"profit_cent": pickup.ProfitAmountCent,
},
}); 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
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 置 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,
"profit_amount_cent": pickup.ProfitAmountCent,
},
}); 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()
if isEmptyPickupAccountSnapshot(dto.AccountSnapshot) {
if snapshot, err := r.fallbackAccountSnapshot(ctx, row.ListingID, row.AccountID); err == nil {
dto.AccountSnapshot = snapshot
}
}
return &dto, nil
}
func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*PaginatedResult, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
db := r.baseQuery(ctx)
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("p.status = ?", status)
}
if 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, false)
}
func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query SellerPickupQuery) (*PaginatedResult, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
db := r.baseQuery(ctx).Where("p.owner_id = ?", ownerID)
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("p.status = ?", status)
}
return r.paginatePickups(db, query.Page, query.PageSize, true)
}
func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int, sellerView bool) (*PaginatedResult, error) {
page, pageSize = normalizePaging(page, pageSize)
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var rows []pickupRow
if err := db.Order("p.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]PickupDTO, 0, len(rows))
for _, row := range rows {
item := row.toDTO()
item.AccountSnapshot = nil
if sellerView {
item.WebsiteProfitCent = 0
item.ProfitAmountCent = 0
item.BuyerRatio = 0
}
items = append(items, item)
}
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, 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").
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 []availableListingRow
if err := db.Order("l.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
return nil, err
}
items := make([]AvailableListingDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO())
}
return &AvailableListingsResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
}
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).Table("admin_pickups AS p").
Select("p.*, l.listing_no, a.title AS account_title, a.server_region, a.login_platform, owner.phone AS owner_phone").
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,
ListingPriceCent: row.ListingPriceCent,
OwnerPriceCent: row.OwnerPriceCent,
WebsiteProfitCent: row.WebsiteProfitCent,
ProfitAmountCent: row.ProfitAmountCent,
SellerRatio: row.SellerRatio,
BuyerRatio: row.BuyerRatio,
AccountSnapshot: row.AccountSnapshot,
SettleAmountCent: row.SettleAmountCent,
Status: row.Status,
Remark: row.Remark,
CompleteRemark: row.CompleteRemark,
CreatedAt: row.CreatedAt,
CompletedAt: row.CompletedAt,
CancelledAt: row.CancelledAt,
}
}
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
}
func (row availableListingRow) toDTO() AvailableListingDTO {
snapshot := buildPickupPriceSnapshot(
model.RentalListing{PriceCent: row.ListingPriceCent},
model.GameAccount{HafCoinAmount: row.HafCoinAmount, AssetSummary: row.AssetSummary},
)
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,
WebsiteProfitCent: snapshot.WebsiteProfitCent,
SellerRatio: snapshot.SellerRatio,
BuyerRatio: snapshot.BuyerRatio,
}
}
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
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)
ownerPriceCent := yuanToPickupCent(readPickupNumber(breakdown["seller_total_price"]))
if ownerPriceCent <= 0 || (listingPriceCent > 0 && ownerPriceCent > listingPriceCent) {
ownerPriceCent = listingPriceCent
}
websiteProfitCent := yuanToPickupCent(readPickupNumber(breakdown["platform_markup_amount"]))
if websiteProfitCent <= 0 {
websiteProfitCent = listingPriceCent - ownerPriceCent
}
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: ownerPriceCent,
WebsiteProfitCent: websiteProfitCent,
SellerRatio: roundPickupRatio(sellerRatio),
BuyerRatio: roundPickupRatio(buyerRatio),
}
}
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
}