401 lines
12 KiB
Go
401 lines
12 KiB
Go
package listing
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"hfb_sys/backend/internal/listingstatus"
|
|
"hfb_sys/backend/internal/model"
|
|
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateRequest, reviewRequired bool, publishCooldown time.Duration) (*ListingDTO, error) {
|
|
var dto *ListingDTO
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
now := time.Now()
|
|
if err := r.ensureCreateCooldown(tx, ownerID, publishCooldown, now); err != nil {
|
|
return err
|
|
}
|
|
listingNo, err := r.nextListingNo(tx, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
screenshots, err := marshalScreenshots(req.ScreenshotURLS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
assetSummary, err := marshalAssetSummary(req.AssetSummary)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired)
|
|
account := model.GameAccount{
|
|
OwnerID: ownerID,
|
|
GameName: "delta_force",
|
|
ServerRegion: req.ServerRegion,
|
|
LoginPlatform: req.LoginPlatform,
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
RankLevel: req.RankLevel,
|
|
HafCoinAmount: req.HafCoinAmount,
|
|
AssetSummary: assetSummary,
|
|
ScreenshotURLS: screenshots,
|
|
Status: listingStatus,
|
|
}
|
|
if err := tx.Create(&account).Error; err != nil {
|
|
return err
|
|
}
|
|
priceCent := normalizedListingPriceCent(req)
|
|
listing := model.RentalListing{
|
|
ListingNo: listingNo,
|
|
AccountID: account.ID,
|
|
OwnerID: ownerID,
|
|
PriceCent: priceCent,
|
|
DepositAmountCent: req.DepositAmountCent,
|
|
Status: listingStatus,
|
|
ReviewStatus: reviewStatus,
|
|
PublishedAt: publishedAt,
|
|
}
|
|
if err := tx.Create(&listing).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := listingstatus.AppendTransition(tx, &listing, "", listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "发布上架"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 发布提交时建发布群
|
|
var conversationID uint64
|
|
if r.chatCreator != nil {
|
|
convID, err := r.chatCreator.EnsureListingConversation(tx, listing, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
conversationID = convID
|
|
}
|
|
|
|
dto = toDTO(account, listing)
|
|
dto.ListingGroupConversationID = conversationID
|
|
return nil
|
|
})
|
|
return dto, err
|
|
}
|
|
|
|
func (r *Repository) ensureCreateCooldown(tx *gorm.DB, ownerID uint64, cooldown time.Duration, now time.Time) error {
|
|
if cooldown <= 0 {
|
|
return nil
|
|
}
|
|
|
|
var user model.User
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").First(&user, ownerID).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
var latest model.RentalListing
|
|
err := tx.Where("owner_id = ?", ownerID).
|
|
Order("created_at DESC, id DESC").
|
|
First(&latest).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
remaining := latest.CreatedAt.Add(cooldown).Sub(now)
|
|
if remaining > 0 {
|
|
return PublishCooldownError{Cooldown: cooldown, Remaining: remaining}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type externalUploadCreate struct {
|
|
UploaderName string
|
|
SourceChannel string
|
|
ClientUploadTime *time.Time
|
|
ClientIP string
|
|
RawPayload []byte
|
|
ParsedPayload []byte
|
|
}
|
|
|
|
func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload externalUploadCreate, req CreateRequest) (*ListingDTO, error) {
|
|
var dto *ListingDTO
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
listingNo, err := r.nextListingNo(tx, time.Now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
admin, err := r.findActiveUploadAdmin(tx, upload.UploaderName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
owner, err := r.ensureUploadOwnerUser(tx, admin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
screenshots, err := marshalScreenshots(req.ScreenshotURLS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
assetSummary, err := marshalAssetSummary(req.AssetSummary)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
account := model.GameAccount{
|
|
OwnerID: owner.ID,
|
|
GameName: "delta_force",
|
|
ServerRegion: req.ServerRegion,
|
|
LoginPlatform: req.LoginPlatform,
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
RankLevel: req.RankLevel,
|
|
HafCoinAmount: req.HafCoinAmount,
|
|
AssetSummary: assetSummary,
|
|
ScreenshotURLS: screenshots,
|
|
Status: "draft",
|
|
}
|
|
if err := tx.Create(&account).Error; err != nil {
|
|
return err
|
|
}
|
|
priceCent := normalizedListingPriceCent(req)
|
|
listing := model.RentalListing{
|
|
ListingNo: listingNo,
|
|
AccountID: account.ID,
|
|
OwnerID: owner.ID,
|
|
PriceCent: priceCent,
|
|
DepositAmountCent: req.DepositAmountCent,
|
|
Status: "draft",
|
|
ReviewStatus: "pending",
|
|
HandoffMode: "platform",
|
|
SettlementMode: "platform_managed",
|
|
ManagedAdminID: &admin.ID,
|
|
}
|
|
if err := tx.Create(&listing).Error; err != nil {
|
|
return err
|
|
}
|
|
var conversationID uint64
|
|
if r.chatCreator != nil {
|
|
convID, err := r.chatCreator.EnsureListingConversation(tx, listing, admin.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
conversationID = convID
|
|
}
|
|
matchedAdminID := admin.ID
|
|
ownerID := owner.ID
|
|
listingID := listing.ID
|
|
uploadRow := model.ListingUpload{
|
|
UploaderName: upload.UploaderName,
|
|
SourceChannel: upload.SourceChannel,
|
|
MatchedAdminID: &matchedAdminID,
|
|
OwnerID: &ownerID,
|
|
ClientUploadTime: upload.ClientUploadTime,
|
|
ClientIP: upload.ClientIP,
|
|
RawPayload: datatypes.JSON(upload.RawPayload),
|
|
ParsedPayload: datatypes.JSON(upload.ParsedPayload),
|
|
ListingID: &listingID,
|
|
Status: "draft_created",
|
|
}
|
|
if err := tx.Create(&uploadRow).Error; err != nil {
|
|
return err
|
|
}
|
|
dto = toDTO(account, listing)
|
|
dto.ListingGroupConversationID = conversationID
|
|
return nil
|
|
})
|
|
return dto, err
|
|
}
|
|
|
|
func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint64, req UpdateRequest, reviewRequired bool) (*ListingDTO, error) {
|
|
var dto *ListingDTO
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listingLockedForOwnerMutation(listing) {
|
|
return ErrListingLocked
|
|
}
|
|
// 仅允许草稿/已下架状态编辑;已上架的必须先主动下架,避免在线编辑影响正在出租的账号
|
|
if listing.Status != "draft" && listing.Status != "offline" {
|
|
return ErrListingMustOfflineToEdit
|
|
}
|
|
|
|
account.Title = req.Title
|
|
account.Description = req.Description
|
|
account.ServerRegion = req.ServerRegion
|
|
account.LoginPlatform = req.LoginPlatform
|
|
account.RankLevel = req.RankLevel
|
|
account.HafCoinAmount = req.HafCoinAmount
|
|
assetSummary, err := marshalAssetSummary(req.AssetSummary)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
account.AssetSummary = assetSummary
|
|
screenshots, err := marshalScreenshots(req.ScreenshotURLS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
account.ScreenshotURLS = screenshots
|
|
|
|
listing.PriceCent = normalizedListingPriceCent(req)
|
|
listing.DepositAmountCent = req.DepositAmountCent
|
|
fromStatus := listing.Status
|
|
listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired)
|
|
listing.Status = listingStatus
|
|
listing.ReviewStatus = reviewStatus
|
|
listing.ReviewReason = ""
|
|
listing.PublishedAt = publishedAt
|
|
account.Status = listingStatus
|
|
if err := tx.Save(account).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(listing).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "编辑后上架"); err != nil {
|
|
return err
|
|
}
|
|
dto = toDTO(*account, *listing)
|
|
return nil
|
|
})
|
|
return dto, err
|
|
}
|
|
|
|
func (r *Repository) SubmitReview(ctx context.Context, ownerID uint64, listingID uint64, reviewRequired bool) (*ListingDTO, error) {
|
|
var dto *ListingDTO
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listingLockedForOwnerMutation(listing) {
|
|
return ErrListingLocked
|
|
}
|
|
fromStatus := listing.Status
|
|
listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired)
|
|
listing.Status = listingStatus
|
|
listing.ReviewStatus = reviewStatus
|
|
listing.ReviewReason = ""
|
|
listing.PublishedAt = publishedAt
|
|
account.Status = listingStatus
|
|
if err := tx.Save(account).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(listing).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "提交发布"); err != nil {
|
|
return err
|
|
}
|
|
dto = toDTO(*account, *listing)
|
|
return nil
|
|
})
|
|
return dto, err
|
|
}
|
|
|
|
func (r *Repository) findActiveUploadAdmin(tx *gorm.DB, uploaderName string) (*model.AdminUser, error) {
|
|
uploaderName = strings.TrimSpace(uploaderName)
|
|
var admin model.AdminUser
|
|
if err := tx.Where("username = ? AND status = ?", uploaderName, "active").First(&admin).Error; err == nil {
|
|
return &admin, nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
|
|
var admins []model.AdminUser
|
|
if err := tx.Where("nickname = ? AND status = ?", uploaderName, "active").Limit(2).Find(&admins).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
switch len(admins) {
|
|
case 0:
|
|
return nil, ErrUploaderNotFound
|
|
case 1:
|
|
return &admins[0], nil
|
|
default:
|
|
return nil, ErrUploaderAmbiguous
|
|
}
|
|
}
|
|
|
|
func (r *Repository) ensureUploadOwnerUser(tx *gorm.DB, admin *model.AdminUser) (*model.User, error) {
|
|
phone := fmt.Sprintf("admin:%d", admin.ID)
|
|
nickname := strings.TrimSpace(admin.Nickname)
|
|
if nickname == "" {
|
|
nickname = admin.Username
|
|
}
|
|
var user model.User
|
|
err := tx.Where("phone = ?", phone).First(&user).Error
|
|
if err == nil {
|
|
updates := map[string]any{
|
|
"nickname": nickname,
|
|
"status": "active",
|
|
"realname_status": "verified",
|
|
}
|
|
if err := tx.Model(&user).Updates(updates).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
user.Nickname = nickname
|
|
user.Status = "active"
|
|
user.RealnameStatus = "verified"
|
|
return &user, nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
user = model.User{
|
|
Phone: phone,
|
|
Nickname: nickname,
|
|
RealnameStatus: "verified",
|
|
RiskStatus: "normal",
|
|
CreditScore: 100,
|
|
Status: "active",
|
|
}
|
|
if err := tx.Create(&user).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func (r *Repository) Offline(ctx context.Context, ownerID uint64, listingID uint64) (*ListingDTO, error) {
|
|
var dto *ListingDTO
|
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listingLockedForOwnerMutation(listing) {
|
|
return ErrListingLocked
|
|
}
|
|
fromStatus := listing.Status
|
|
listing.Status = "offline"
|
|
listing.ReviewStatus = "none"
|
|
listing.ReviewReason = "号主已手动下架"
|
|
listing.PublishedAt = nil
|
|
account.Status = "offline"
|
|
if err := tx.Save(account).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(listing).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceSeller, listingstatus.ActorUser, ownerID, "号主手动下架"); err != nil {
|
|
return err
|
|
}
|
|
dto = toDTO(*account, *listing)
|
|
return nil
|
|
})
|
|
return dto, err
|
|
}
|
|
|
|
func listingLockedForOwnerMutation(listing *model.RentalListing) bool {
|
|
if listing == nil {
|
|
return true
|
|
}
|
|
return listing.Status == "rented" || listing.Status == "completed" ||
|
|
listing.Status == "sealed" || listing.InTransaction
|
|
}
|