810 lines
22 KiB
Go
810 lines
22 KiB
Go
package listing
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"hfb_sys/backend/internal/model"
|
|
"hfb_sys/backend/internal/modules/notification"
|
|
|
|
"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}
|
|
}
|
|
|
|
func initialPublishState(reviewRequired bool) (string, string, *time.Time) {
|
|
if reviewRequired {
|
|
return "draft", "pending", nil
|
|
}
|
|
now := time.Now()
|
|
return "published", "approved", &now
|
|
}
|
|
|
|
func (r *Repository) Create(ownerID uint64, req CreateRequest, reviewRequired bool) (*ListingDTO, error) {
|
|
var dto *ListingDTO
|
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
|
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
|
|
}
|
|
price := normalizedListingPrice(req)
|
|
listing := model.RentalListing{
|
|
AccountID: account.ID,
|
|
OwnerID: ownerID,
|
|
Price: price,
|
|
PriceHourly: listingHourlyPrice(req, price),
|
|
PriceDaily: listingDailyPrice(req, price),
|
|
PriceWeekly: listingWeeklyPrice(req, price),
|
|
DepositAmount: req.DepositAmount,
|
|
Status: listingStatus,
|
|
ReviewStatus: reviewStatus,
|
|
PublishedAt: publishedAt,
|
|
}
|
|
if err := tx.Create(&listing).Error; err != nil {
|
|
return err
|
|
}
|
|
dto = toDTO(account, listing)
|
|
return nil
|
|
})
|
|
return dto, err
|
|
}
|
|
|
|
func (r *Repository) Update(ownerID uint64, listingID uint64, req UpdateRequest, reviewRequired bool) (*ListingDTO, error) {
|
|
var dto *ListingDTO
|
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
|
listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listing.Status == "rented" || listing.InTransaction {
|
|
return ErrListingLocked
|
|
}
|
|
|
|
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
|
|
|
|
price := normalizedListingPrice(req)
|
|
listing.Price = price
|
|
listing.PriceHourly = listingHourlyPrice(req, price)
|
|
listing.PriceDaily = listingDailyPrice(req, price)
|
|
listing.PriceWeekly = listingWeeklyPrice(req, price)
|
|
listing.DepositAmount = req.DepositAmount
|
|
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
|
|
}
|
|
dto = toDTO(*account, *listing)
|
|
return nil
|
|
})
|
|
return dto, err
|
|
}
|
|
|
|
func (r *Repository) SubmitReview(ownerID uint64, listingID uint64, reviewRequired bool) (*ListingDTO, error) {
|
|
var dto *ListingDTO
|
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
|
listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listing.Status == "rented" || listing.InTransaction {
|
|
return ErrListingLocked
|
|
}
|
|
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
|
|
}
|
|
dto = toDTO(*account, *listing)
|
|
return nil
|
|
})
|
|
return dto, err
|
|
}
|
|
|
|
func (r *Repository) ListPendingReview() ([]ListingDTO, error) {
|
|
var rows []listingRow
|
|
err := r.baseQuery().
|
|
Where("l.review_status = ?", "pending").
|
|
Order("l.updated_at ASC, l.id ASC").
|
|
Limit(200).
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rowsToDTO(rows), nil
|
|
}
|
|
|
|
func (r *Repository) ListAdmin(query AdminListQuery) ([]ListingDTO, error) {
|
|
limit := query.Limit
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 200
|
|
}
|
|
|
|
db := r.baseQuery()
|
|
if query.OwnerID > 0 {
|
|
db = db.Where("l.owner_id = ?", query.OwnerID)
|
|
}
|
|
if query.Status != "" {
|
|
db = db.Where("l.status = ?", query.Status)
|
|
}
|
|
if query.ReviewStatus != "" {
|
|
db = db.Where("l.review_status = ?", query.ReviewStatus)
|
|
}
|
|
|
|
var rows []listingRow
|
|
err := db.Order("l.id DESC").Limit(limit).Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rowsToDTO(rows), nil
|
|
}
|
|
|
|
func (r *Repository) FindAdmin(listingID uint64) (*ListingDTO, error) {
|
|
return r.findDTO("l.id = ?", listingID)
|
|
}
|
|
|
|
func (r *Repository) AdminOffline(adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) {
|
|
return r.adminUpdateStatus(adminID, listingID, req, meta, "offline", "offline", "listing.admin_offline", "商品已被后台下架", "你的租号商品已被后台下架,请查看原因后处理。")
|
|
}
|
|
|
|
func (r *Repository) AdminMarkAbnormal(adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) {
|
|
return r.adminUpdateStatus(adminID, listingID, req, meta, "abnormal", "abnormal", "listing.mark_abnormal", "商品已被标记异常", "你的租号商品已被后台标记异常,请联系客服处理。")
|
|
}
|
|
|
|
func (r *Repository) adminUpdateStatus(adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta, listingStatus string, accountStatus string, action string, title string, content string) (*ListingDTO, error) {
|
|
var dto *ListingDTO
|
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
|
listing, account, err := r.findForReviewUpdate(tx, listingID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listing.Status == "rented" || listing.InTransaction {
|
|
return ErrListingLocked
|
|
}
|
|
beforeListingStatus := listing.Status
|
|
beforeAccountStatus := account.Status
|
|
beforeReviewReason := listing.ReviewReason
|
|
listing.Status = listingStatus
|
|
listing.ReviewReason = req.Reason
|
|
if listingStatus != "published" {
|
|
listing.PublishedAt = nil
|
|
}
|
|
account.Status = accountStatus
|
|
if err := tx.Save(account).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(listing).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := notification.Append(tx, notification.Entry{
|
|
UserID: listing.OwnerID,
|
|
Type: "listing_admin",
|
|
Title: title,
|
|
Content: content,
|
|
BizType: "listing",
|
|
BizID: &listingID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := appendAuditLog(tx, adminID, action, "listing", listing.ID, meta, map[string]any{
|
|
"listing_id": listing.ID,
|
|
"account_id": account.ID,
|
|
"owner_id": listing.OwnerID,
|
|
"reason": req.Reason,
|
|
"before_listing_status": beforeListingStatus,
|
|
"after_listing_status": listing.Status,
|
|
"before_account_status": beforeAccountStatus,
|
|
"after_account_status": account.Status,
|
|
"before_review_reason": beforeReviewReason,
|
|
"after_review_reason": listing.ReviewReason,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
dto = toDTO(*account, *listing)
|
|
return nil
|
|
})
|
|
return dto, err
|
|
}
|
|
|
|
func (r *Repository) Approve(listingID uint64) (*ListingDTO, error) {
|
|
var dto *ListingDTO
|
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
|
listing, account, err := r.findForReviewUpdate(tx, listingID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listing.Status == "rented" || listing.InTransaction {
|
|
return ErrListingLocked
|
|
}
|
|
now := time.Now()
|
|
listing.Status = "published"
|
|
listing.ReviewStatus = "approved"
|
|
listing.ReviewReason = ""
|
|
listing.PublishedAt = &now
|
|
account.Status = "published"
|
|
if err := tx.Save(account).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(listing).Error; err != nil {
|
|
return err
|
|
}
|
|
listingID := listing.ID
|
|
if err := notification.Append(tx, notification.Entry{
|
|
UserID: listing.OwnerID,
|
|
Type: "listing_review",
|
|
Title: "发布审核通过",
|
|
Content: "你的租号发布已审核通过并上架。",
|
|
BizType: "listing",
|
|
BizID: &listingID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
dto = toDTO(*account, *listing)
|
|
return nil
|
|
})
|
|
return dto, err
|
|
}
|
|
|
|
func (r *Repository) Reject(listingID uint64, req ReviewRequest) (*ListingDTO, error) {
|
|
var dto *ListingDTO
|
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
|
listing, account, err := r.findForReviewUpdate(tx, listingID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listing.Status == "rented" || listing.InTransaction {
|
|
return ErrListingLocked
|
|
}
|
|
listing.Status = "draft"
|
|
listing.ReviewStatus = "rejected"
|
|
listing.ReviewReason = req.Reason
|
|
listing.PublishedAt = nil
|
|
account.Status = "draft"
|
|
if err := tx.Save(account).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Save(listing).Error; err != nil {
|
|
return err
|
|
}
|
|
listingID := listing.ID
|
|
if err := notification.Append(tx, notification.Entry{
|
|
UserID: listing.OwnerID,
|
|
Type: "listing_review",
|
|
Title: "发布审核未通过",
|
|
Content: "你的租号发布未通过审核,请根据原因修改后重新提交。",
|
|
BizType: "listing",
|
|
BizID: &listingID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
dto = toDTO(*account, *listing)
|
|
return nil
|
|
})
|
|
return dto, err
|
|
}
|
|
|
|
func (r *Repository) Offline(ownerID uint64, listingID uint64) error {
|
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
|
listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if listing.Status == "rented" || listing.InTransaction {
|
|
return ErrListingLocked
|
|
}
|
|
listing.Status = "offline"
|
|
account.Status = "offline"
|
|
if err := tx.Save(account).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Save(listing).Error
|
|
})
|
|
}
|
|
|
|
func (r *Repository) ListPublic() ([]ListingDTO, error) {
|
|
var rows []listingRow
|
|
err := r.baseQuery().
|
|
Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false).
|
|
Order("l.published_at DESC, l.id DESC").
|
|
Limit(100).
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return publicListings(rowsToDTO(rows)), nil
|
|
}
|
|
|
|
func (r *Repository) ListMine(ownerID uint64) ([]ListingDTO, error) {
|
|
var rows []listingRow
|
|
err := r.baseQuery().
|
|
Where("l.owner_id = ?", ownerID).
|
|
Order("l.id DESC").
|
|
Scan(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rowsToDTO(rows), nil
|
|
}
|
|
|
|
func (r *Repository) FindPublic(id uint64) (*ListingDTO, error) {
|
|
dto, err := r.findDTO("l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
applyPublicListingURLs(dto)
|
|
return dto, nil
|
|
}
|
|
|
|
func (r *Repository) FindPublicCoverKey(id uint64) (string, error) {
|
|
return r.FindPublicScreenshotKey(id, 0)
|
|
}
|
|
|
|
func (r *Repository) FindPublicScreenshotKey(id uint64, index int) (string, error) {
|
|
if index < 0 {
|
|
return "", gorm.ErrRecordNotFound
|
|
}
|
|
dto, err := r.findDTO("l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if index >= len(dto.ScreenshotURLS) {
|
|
return "", gorm.ErrRecordNotFound
|
|
}
|
|
if key := extractListingObjectKey(dto.ScreenshotURLS[index]); key != "" {
|
|
return key, nil
|
|
}
|
|
return "", gorm.ErrRecordNotFound
|
|
}
|
|
|
|
func (r *Repository) FindMine(ownerID uint64, id uint64) (*ListingDTO, error) {
|
|
return r.findDTO("l.id = ? AND l.owner_id = ?", id, ownerID)
|
|
}
|
|
|
|
func (r *Repository) findOwnedForUpdate(tx *gorm.DB, ownerID uint64, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
|
|
var listing model.RentalListing
|
|
if err := tx.Where("id = ? AND owner_id = ?", listingID, ownerID).First(&listing).Error; err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var account model.GameAccount
|
|
if err := tx.Where("id = ? AND owner_id = ?", listing.AccountID, ownerID).First(&account).Error; err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &listing, &account, nil
|
|
}
|
|
|
|
func (r *Repository) findDTO(where string, args ...any) (*ListingDTO, error) {
|
|
var row listingRow
|
|
err := r.baseQuery().
|
|
Where(where, args...).
|
|
First(&row).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dto := row.toDTO()
|
|
return &dto, nil
|
|
}
|
|
|
|
func (r *Repository) baseQuery() *gorm.DB {
|
|
return r.db.Table("rental_listings AS l").
|
|
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
|
|
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname`).
|
|
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
|
Joins("LEFT JOIN users AS u ON u.id = l.owner_id")
|
|
}
|
|
|
|
func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
|
|
var listing model.RentalListing
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, listingID).Error; err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var account model.GameAccount
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, listing.AccountID).Error; err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &listing, &account, nil
|
|
}
|
|
|
|
type listingRow struct {
|
|
model.RentalListing
|
|
Title string
|
|
OwnerPhone string
|
|
OwnerNickname string
|
|
Description string
|
|
GameName string
|
|
ServerRegion string
|
|
LoginPlatform string
|
|
RankLevel string
|
|
HafCoinAmount int64
|
|
AssetSummary datatypes.JSON `gorm:"column:asset_summary"`
|
|
ScreenshotURLS datatypes.JSON `gorm:"column:screenshot_urls"`
|
|
}
|
|
|
|
func rowsToDTO(rows []listingRow) []ListingDTO {
|
|
items := make([]ListingDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, row.toDTO())
|
|
}
|
|
return items
|
|
}
|
|
|
|
func normalizedListingPrice(req CreateRequest) float64 {
|
|
if req.Price > 0 {
|
|
return req.Price
|
|
}
|
|
if req.PriceDaily > 0 {
|
|
return req.PriceDaily
|
|
}
|
|
if req.PriceHourly > 0 {
|
|
return req.PriceHourly * 24
|
|
}
|
|
if req.PriceWeekly > 0 {
|
|
return req.PriceWeekly / 7
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func listingHourlyPrice(req CreateRequest, price float64) float64 {
|
|
if req.PriceHourly > 0 {
|
|
return req.PriceHourly
|
|
}
|
|
return price / 24
|
|
}
|
|
|
|
func listingDailyPrice(req CreateRequest, price float64) float64 {
|
|
if req.PriceDaily > 0 {
|
|
return req.PriceDaily
|
|
}
|
|
return price
|
|
}
|
|
|
|
func listingWeeklyPrice(req CreateRequest, price float64) float64 {
|
|
if req.PriceWeekly > 0 {
|
|
return req.PriceWeekly
|
|
}
|
|
return price * 7
|
|
}
|
|
|
|
func listingDisplayPrices(price float64, hourly float64, daily float64, weekly float64) (float64, float64, float64, float64) {
|
|
if price <= 0 {
|
|
switch {
|
|
case daily > 0:
|
|
price = daily
|
|
case hourly > 0:
|
|
price = hourly * 24
|
|
case weekly > 0:
|
|
price = weekly / 7
|
|
}
|
|
}
|
|
if daily <= 0 {
|
|
daily = price
|
|
}
|
|
if hourly <= 0 && price > 0 {
|
|
hourly = price / 24
|
|
}
|
|
if weekly <= 0 && price > 0 {
|
|
weekly = price * 7
|
|
}
|
|
return price, hourly, daily, weekly
|
|
}
|
|
|
|
func publicListings(items []ListingDTO) []ListingDTO {
|
|
for index := range items {
|
|
applyPublicListingURLs(&items[index])
|
|
}
|
|
return items
|
|
}
|
|
|
|
func applyPublicListingURLs(item *ListingDTO) {
|
|
item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus)
|
|
if item.AssetSummary != nil {
|
|
delete(item.AssetSummary, "price_breakdown")
|
|
}
|
|
}
|
|
|
|
func (row listingRow) toDTO() ListingDTO {
|
|
assetSummary := decodeAssetSummary(row.AssetSummary)
|
|
screenshotURLS := cleanScreenshotURLs(decodeScreenshots(row.ScreenshotURLS))
|
|
price, priceHourly, priceDaily, priceWeekly := listingDisplayPrices(row.Price, row.PriceHourly, row.PriceDaily, row.PriceWeekly)
|
|
return ListingDTO{
|
|
ID: row.ID,
|
|
AccountID: row.AccountID,
|
|
OwnerID: row.OwnerID,
|
|
OwnerPhone: row.OwnerPhone,
|
|
OwnerNickname: row.OwnerNickname,
|
|
Title: row.Title,
|
|
Description: row.Description,
|
|
GameName: row.GameName,
|
|
ServerRegion: row.ServerRegion,
|
|
LoginPlatform: row.LoginPlatform,
|
|
RankLevel: row.RankLevel,
|
|
HafCoinAmount: row.HafCoinAmount,
|
|
AssetSummary: assetSummary,
|
|
ScreenshotURLS: screenshotURLS,
|
|
CoverURL: publicCoverURL(row.ID, screenshotURLS, row.Status, row.ReviewStatus),
|
|
Price: price,
|
|
PriceHourly: priceHourly,
|
|
PriceDaily: priceDaily,
|
|
PriceWeekly: priceWeekly,
|
|
DepositAmount: row.DepositAmount,
|
|
IsAccelerated: isAcceleratedSale(assetSummary),
|
|
InTransaction: row.InTransaction,
|
|
Status: row.Status,
|
|
ReviewStatus: row.ReviewStatus,
|
|
ReviewReason: row.ReviewReason,
|
|
PublishedAt: row.PublishedAt,
|
|
CreatedAt: row.CreatedAt,
|
|
UpdatedAt: row.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
|
|
assetSummary := decodeAssetSummary(account.AssetSummary)
|
|
screenshotURLS := cleanScreenshotURLs(decodeScreenshots(account.ScreenshotURLS))
|
|
price, priceHourly, priceDaily, priceWeekly := listingDisplayPrices(listing.Price, listing.PriceHourly, listing.PriceDaily, listing.PriceWeekly)
|
|
return &ListingDTO{
|
|
ID: listing.ID,
|
|
AccountID: account.ID,
|
|
OwnerID: listing.OwnerID,
|
|
Title: account.Title,
|
|
Description: account.Description,
|
|
GameName: account.GameName,
|
|
ServerRegion: account.ServerRegion,
|
|
LoginPlatform: account.LoginPlatform,
|
|
RankLevel: account.RankLevel,
|
|
HafCoinAmount: account.HafCoinAmount,
|
|
AssetSummary: assetSummary,
|
|
ScreenshotURLS: screenshotURLS,
|
|
CoverURL: publicCoverURL(listing.ID, screenshotURLS, listing.Status, listing.ReviewStatus),
|
|
Price: price,
|
|
PriceHourly: priceHourly,
|
|
PriceDaily: priceDaily,
|
|
PriceWeekly: priceWeekly,
|
|
DepositAmount: listing.DepositAmount,
|
|
IsAccelerated: isAcceleratedSale(assetSummary),
|
|
InTransaction: listing.InTransaction,
|
|
Status: listing.Status,
|
|
ReviewStatus: listing.ReviewStatus,
|
|
ReviewReason: listing.ReviewReason,
|
|
PublishedAt: listing.PublishedAt,
|
|
CreatedAt: listing.CreatedAt,
|
|
UpdatedAt: listing.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func marshalScreenshots(urls []string) (datatypes.JSON, error) {
|
|
cleaned := cleanScreenshotURLs(urls)
|
|
if len(cleaned) > 12 {
|
|
cleaned = cleaned[:12]
|
|
}
|
|
raw, err := json.Marshal(cleaned)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return datatypes.JSON(raw), nil
|
|
}
|
|
|
|
func marshalAssetSummary(summary map[string]any) (datatypes.JSON, error) {
|
|
if summary == nil {
|
|
return nil, nil
|
|
}
|
|
raw, err := json.Marshal(summary)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return datatypes.JSON(raw), nil
|
|
}
|
|
|
|
func decodeScreenshots(raw datatypes.JSON) []string {
|
|
if len(raw) == 0 {
|
|
return []string{}
|
|
}
|
|
var urls []string
|
|
if err := json.Unmarshal(raw, &urls); err != nil {
|
|
return []string{}
|
|
}
|
|
return urls
|
|
}
|
|
|
|
func decodeAssetSummary(raw datatypes.JSON) map[string]any {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var summary map[string]any
|
|
if err := json.Unmarshal(raw, &summary); err != nil {
|
|
return nil
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func isAcceleratedSale(summary map[string]any) bool {
|
|
if summary == nil {
|
|
return false
|
|
}
|
|
breakdown, ok := summary["price_breakdown"].(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
referenceRatio := readSummaryNumber(breakdown["seller_reference_ratio"])
|
|
sellerRatio := readSummaryNumber(breakdown["seller_ratio"])
|
|
acceleratedRatio := readSummaryNumber(breakdown["accelerated_sale_ratio"])
|
|
if referenceRatio <= 0 {
|
|
return false
|
|
}
|
|
return sellerRatio > referenceRatio || acceleratedRatio > referenceRatio
|
|
}
|
|
|
|
func readSummaryNumber(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 cleanScreenshotURLs(urls []string) []string {
|
|
cleaned := make([]string, 0, len(urls))
|
|
seen := make(map[string]struct{}, len(urls))
|
|
for _, url := range urls {
|
|
url = strings.TrimSpace(url)
|
|
if url == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[url]; ok {
|
|
continue
|
|
}
|
|
seen[url] = struct{}{}
|
|
cleaned = append(cleaned, url)
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
func firstScreenshotURL(urls []string) string {
|
|
if len(urls) == 0 {
|
|
return ""
|
|
}
|
|
return urls[0]
|
|
}
|
|
|
|
func publicCoverURL(listingID uint64, urls []string, status string, reviewStatus string) string {
|
|
fallback := firstScreenshotURL(urls)
|
|
if status != "published" || reviewStatus != "approved" || extractListingObjectKey(fallback) == "" {
|
|
return fallback
|
|
}
|
|
return "/api/listings/" + strconv.FormatUint(listingID, 10) + "/cover"
|
|
}
|
|
|
|
func publicScreenshotURLs(listingID uint64, urls []string, status string, reviewStatus string) []string {
|
|
if status != "published" || reviewStatus != "approved" {
|
|
return urls
|
|
}
|
|
publicURLs := make([]string, 0, len(urls))
|
|
for index, fileURL := range urls {
|
|
if extractListingObjectKey(fileURL) == "" {
|
|
publicURLs = append(publicURLs, fileURL)
|
|
continue
|
|
}
|
|
publicURLs = append(publicURLs, "/api/listings/"+strconv.FormatUint(listingID, 10)+"/screenshots/"+strconv.Itoa(index))
|
|
}
|
|
return publicURLs
|
|
}
|
|
|
|
func extractListingObjectKey(fileURL string) string {
|
|
if fileURL == "" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(fileURL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
key := parsed.Query().Get("key")
|
|
if key == "" {
|
|
return ""
|
|
}
|
|
if !strings.HasPrefix(key, "listing/") || strings.Contains(key, "..") {
|
|
return ""
|
|
}
|
|
return key
|
|
}
|
|
|
|
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error {
|
|
raw, err := json.Marshal(detail)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
row := model.AuditLog{
|
|
ActorType: "admin",
|
|
ActorID: actorID,
|
|
Action: action,
|
|
BizType: bizType,
|
|
BizID: &bizID,
|
|
IP: meta.IP,
|
|
UserAgent: meta.UserAgent,
|
|
Detail: datatypes.JSON(raw),
|
|
}
|
|
return tx.Create(&row).Error
|
|
}
|
|
|
|
func IsNotFound(err error) bool {
|
|
return errors.Is(err, gorm.ErrRecordNotFound)
|
|
}
|