package listing import ( "encoding/json" "errors" "fmt" "math" "net/url" "sort" "strconv" "strings" "sync" "time" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/pkg/money" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Repository struct { db *gorm.DB publicZoneCountsMu sync.Mutex publicZoneCounts publicZoneCountCache } func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } type publicZoneCountCache struct { Counts map[string]int64 ExpiresAt time.Time } const publicZoneCountCacheTTL = 5 * time.Second 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 { listingNo, err := r.nextListingNo(tx, time.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 } price := normalizedListingPrice(req) depositAmount := roundMoney(req.DepositAmount) listing := model.RentalListing{ ListingNo: listingNo, AccountID: account.ID, OwnerID: ownerID, Price: price, DepositAmount: 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 } type externalUploadCreate struct { UploaderName string ClientUploadTime *time.Time ClientIP string RawPayload []byte ParsedPayload []byte } func (r *Repository) CreateFromExternalUpload(upload externalUploadCreate, req CreateRequest) (*ListingDTO, error) { var dto *ListingDTO err := r.db.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 } listing := model.RentalListing{ ListingNo: listingNo, AccountID: account.ID, OwnerID: owner.ID, Price: normalizedListingPrice(req), DepositAmount: roundMoney(req.DepositAmount), Status: "draft", ReviewStatus: "pending", } if err := tx.Create(&listing).Error; err != nil { return err } matchedAdminID := admin.ID ownerID := owner.ID listingID := listing.ID uploadRow := model.ListingUpload{ UploaderName: upload.UploaderName, 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) 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.DepositAmount = roundMoney(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 = ? AND l.status <> ?", "pending", "offline"). 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) (*AdminListResult, error) { page := query.Page if page <= 0 { page = 1 } pageSize := query.PageSize if pageSize <= 0 { pageSize = query.Limit } if pageSize <= 0 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } countDB := r.applyAdminListFilters(r.db.Table("rental_listings AS l"), query) var total int64 if err := countDB.Count(&total).Error; err != nil { return nil, err } db := r.applyAdminListFilters(r.baseQuery(), query) offset := (page - 1) * pageSize if offset < 0 { offset = 0 } var rows []listingRow err := db.Order("l.id DESC").Limit(pageSize).Offset(offset).Scan(&rows).Error if err != nil { return nil, err } return &AdminListResult{ Items: rowsToDTO(rows), Total: total, Page: page, PageSize: pageSize, }, nil } func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB { 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) } return db } 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) 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) AdjustReviewPrice(adminID uint64, listingID uint64, req AdminPriceAdjustRequest, meta AuditMeta) (*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 } summary := decodeAssetSummary(account.AssetSummary) if summary == nil { summary = map[string]any{} } breakdown := ensurePriceBreakdown(summary) coinWan := float64(account.HafCoinAmount) / 10000 consumablePrice := readSummaryNumber(breakdown["consumable_price"]) if consumablePrice <= 0 { consumablePrice = consumableValue(summary) } sellerTotalPrice := readSummaryNumber(breakdown["seller_total_price"]) if sellerTotalPrice <= 0 { sellerTotalPrice = math.Max(0, listing.Price-consumablePrice) } sellerCoinBasePrice := readSummaryNumber(breakdown["seller_coin_base_price"]) if sellerCoinBasePrice <= 0 { sellerCoinBasePrice = math.Max(0, sellerTotalPrice-consumablePrice) } sellerRatio := readSummaryNumber(breakdown["seller_ratio"]) if sellerRatio <= 0 && sellerCoinBasePrice > 0 { sellerRatio = roundRatio(coinWan / sellerCoinBasePrice) } buyerCoinBasePrice, buyerTotalPrice, buyerRatio := calculateAdminAdjustedPrice(req, coinWan, consumablePrice) if buyerCoinBasePrice <= 0 || buyerTotalPrice <= 0 || buyerRatio <= 0 { return ErrInvalidPrice } beforePrice := listing.Price beforeRatio := readSummaryNumber(breakdown["buyer_ratio"]) if beforeRatio <= 0 && listing.Price > consumablePrice { beforeRatio = roundRatio(coinWan / (listing.Price - consumablePrice)) } listing.Price = buyerTotalPrice summary["publish_ratio"] = buyerRatio breakdown["seller_coin_base_price"] = roundMoney(sellerCoinBasePrice) breakdown["seller_total_price"] = roundMoney(sellerTotalPrice) breakdown["seller_ratio"] = sellerRatio breakdown["buyer_coin_base_price"] = buyerCoinBasePrice breakdown["buyer_total_price"] = buyerTotalPrice breakdown["buyer_ratio"] = buyerRatio breakdown["platform_markup_amount"] = roundMoney(buyerTotalPrice - sellerTotalPrice) breakdown["platform_rule_type"] = "admin_adjusted" breakdown["admin_adjust_reason"] = strings.TrimSpace(req.Reason) breakdown["admin_adjusted_at"] = time.Now().Format(time.RFC3339) breakdown["admin_adjusted_by"] = adminID summary["price_breakdown"] = breakdown assetSummary, err := marshalAssetSummary(summary) if err != nil { return err } account.AssetSummary = assetSummary if err := tx.Save(account).Error; err != nil { return err } if err := tx.Save(listing).Error; err != nil { return err } if err := appendAuditLog(tx, adminID, "listing.adjust_review_price", "listing", listing.ID, meta, map[string]any{ "listing_id": listing.ID, "account_id": account.ID, "owner_id": listing.OwnerID, "before_price": beforePrice, "after_price": listing.Price, "before_buyer_ratio": beforeRatio, "after_buyer_ratio": buyerRatio, "platform_markup": breakdown["platform_markup_amount"], "adjust_reason": req.Reason, "buyer_coin_base": buyerCoinBasePrice, "consumable_price": consumablePrice, "seller_total_price": sellerTotalPrice, }); 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) (*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 } 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 } dto = toDTO(*account, *listing) return nil }) return dto, err } func (r *Repository) ListPublic(query PublicListQuery) (*PublicListResult, error) { page, pageSize := normalizedPublicPage(query) if canListPublicWithSQL(query) { return r.listPublicPage(query, page, pageSize) } 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"). Scan(&rows).Error if err != nil { return nil, err } items := publicListings(rowsToDTO(rows)) baseQuery := query baseQuery.Zone = "" items = filterPublicListings(items, baseQuery) zoneCounts := publicZoneCounts(items) if query.Zone != "" && query.Zone != "all" { items = filterPublicListings(items, query) } sortPublicListings(items, query.Sort) total := int64(len(items)) start := (page - 1) * pageSize if start < 0 { start = 0 } if start >= len(items) { items = []ListingDTO{} } else { end := start + pageSize if end > len(items) { end = len(items) } items = items[start:end] } return &PublicListResult{ Items: items, Total: total, Page: page, PageSize: pageSize, ZoneCounts: zoneCounts, }, nil } func (r *Repository) listPublicPage(query PublicListQuery, page int, pageSize int) (*PublicListResult, error) { var total int64 if err := r.db.Table("rental_listings AS l"). Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). Count(&total).Error; err != nil { return nil, err } var rows []listingRow offset := (page - 1) * pageSize err := applyPublicSQLSort(r.baseQuery(), query.Sort). Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). Limit(pageSize). Offset(offset). Scan(&rows).Error if err != nil { return nil, err } zoneCounts, err := r.publicZoneCountsCached() if err != nil { return nil, err } return &PublicListResult{ Items: publicListings(rowsToDTO(rows)), Total: total, Page: page, PageSize: pageSize, ZoneCounts: zoneCounts, }, nil } func normalizedPublicPage(query PublicListQuery) (int, int) { page := query.Page if page <= 0 { page = 1 } pageSize := query.PageSize if pageSize <= 0 { pageSize = 20 } if pageSize > 50 { pageSize = 50 } return page, pageSize } func canListPublicWithSQL(query PublicListQuery) bool { if query.Keyword != "" { return false } if query.Zone != "" && query.Zone != "all" { return false } if len(query.Server) > 0 || len(query.Region) > 0 || len(query.LoginMethod) > 0 || len(query.Rank) > 0 { return false } if len(query.Insurance) > 0 || len(query.Stamina) > 0 || len(query.Load) > 0 { return false } if len(query.SkinGroup) > 0 || len(query.SkinName) > 0 || len(query.ResourceRanges) > 0 { return false } if query.MinCoin != nil || query.MaxCoin != nil || query.MinPrice != nil || query.MaxPrice != nil { return false } if query.MinDeposit != nil || query.MaxDeposit != nil || query.MinTotal != nil || query.MaxTotal != nil { return false } if query.MinFireLevel != nil || query.MaxFireLevel != nil || query.MinSecretKD != nil || query.MaxSecretKD != nil { return false } switch query.Sort { case "", "published", "recommended", "comprehensive", "priceAsc", "priceDesc", "coinDesc": return true default: return false } } func applyPublicSQLSort(db *gorm.DB, sortKey string) *gorm.DB { switch sortKey { case "priceAsc": return db.Order("l.price ASC, l.published_at DESC, l.id DESC") case "priceDesc": return db.Order("l.price DESC, l.published_at DESC, l.id DESC") case "coinDesc": return db.Order("a.haf_coin_amount DESC, l.published_at DESC, l.id DESC") default: return db.Order("l.published_at DESC, l.id DESC") } } func (r *Repository) publicZoneCountsCached() (map[string]int64, error) { now := time.Now() r.publicZoneCountsMu.Lock() defer r.publicZoneCountsMu.Unlock() if r.publicZoneCounts.Counts != nil && now.Before(r.publicZoneCounts.ExpiresAt) { return copyPublicZoneCounts(r.publicZoneCounts.Counts), nil } var rows []publicZoneRow err := r.db.Table("rental_listings AS l"). Select("a.login_platform, a.haf_coin_amount, a.asset_summary"). Joins("JOIN game_accounts AS a ON a.id = l.account_id"). Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false). Scan(&rows).Error if err != nil { return nil, err } counts := map[string]int64{ "all": int64(len(rows)), "sale": 0, "gift": 0, "night": 0, "password": 0, "highCoin": 0, } for _, row := range rows { summary := decodeAssetSummary(row.AssetSummary) if isAcceleratedSale(summary) { counts["sale"]++ } if hasGiftResourcesSummary(summary) { counts["gift"]++ } if isNightAvailableSummary(summary) { counts["night"]++ } if strings.Contains(row.LoginPlatform, "账密") || strings.Contains(row.LoginPlatform, "账号密码") { counts["password"]++ } if float64(row.HafCoinAmount)/1000000 >= 100 { counts["highCoin"]++ } } r.publicZoneCounts = publicZoneCountCache{ Counts: counts, ExpiresAt: now.Add(publicZoneCountCacheTTL), } return copyPublicZoneCounts(counts), nil } func copyPublicZoneCounts(counts map[string]int64) map[string]int64 { copied := make(map[string]int64, len(counts)) for key, value := range counts { copied[key] = value } return copied } 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 sellerListings(rowsToDTO(rows)), nil } func filterPublicListings(items []ListingDTO, query PublicListQuery) []ListingDTO { filtered := make([]ListingDTO, 0, len(items)) for _, item := range items { if !matchesPublicQuery(item, query) { continue } filtered = append(filtered, item) } return filtered } func matchesPublicQuery(item ListingDTO, query PublicListQuery) bool { if !matchesPublicZone(item, query.Zone) { return false } if keyword := strings.ToLower(strings.TrimSpace(query.Keyword)); keyword != "" && !strings.Contains(publicSearchText(item), keyword) { return false } if !matchesAny(query.Server, strings.TrimSpace(item.ServerRegion)) { return false } if len(query.Region) > 0 && !intersects(query.Region, assetRegionsFromSummary(item.AssetSummary)) { return false } if !matchesAny(query.LoginMethod, strings.TrimSpace(item.LoginPlatform)) { return false } if !matchesAny(query.Rank, strings.TrimSpace(item.RankLevel)) { return false } if !matchesAny(query.Insurance, readAssetString(item.AssetSummary, "season_insurance")) { return false } if !matchesAny(query.Stamina, readAssetString(item.AssetSummary, "stamina_level")) { return false } if !matchesAny(query.Load, readAssetString(item.AssetSummary, "load_level")) { return false } if len(query.SkinName) > 0 { if len(query.SkinGroup) > 0 { if !skinGroupsContainAny(item.AssetSummary, query.SkinGroup, query.SkinName) { return false } } else if !intersects(query.SkinName, skinNamesFromSummary(item.AssetSummary)) { return false } } else if len(query.SkinGroup) > 0 && !skinGroupsHaveAny(item.AssetSummary, query.SkinGroup) { return false } price := item.Price deposit := item.DepositAmount total := price + deposit coinM := coinMFromListing(item) if !numberInRange(coinM, NumberRange{Min: query.MinCoin, Max: query.MaxCoin}) { return false } if !numberInRange(price, NumberRange{Min: query.MinPrice, Max: query.MaxPrice}) { return false } if !numberInRange(deposit, NumberRange{Min: query.MinDeposit, Max: query.MaxDeposit}) { return false } if !numberInRange(total, NumberRange{Min: query.MinTotal, Max: query.MaxTotal}) { return false } if !numberInRange(readSummaryNumber(item.AssetSummary["fire_level"]), NumberRange{Min: query.MinFireLevel, Max: query.MaxFireLevel}) { return false } if !numberInRange(readSummaryNumber(item.AssetSummary["secret_kd"]), NumberRange{Min: query.MinSecretKD, Max: query.MaxSecretKD}) { return false } for resourceKey, resourceRange := range query.ResourceRanges { if !numberInRange(resourceQuantity(item.AssetSummary, resourceKey), resourceRange) { return false } } return true } func sortPublicListings(items []ListingDTO, sortKey string) { sort.SliceStable(items, func(i, j int) bool { a := items[i] b := items[j] switch sortKey { case "priceAsc": return a.Price < b.Price case "priceDesc": return a.Price > b.Price case "coinDesc": return a.HafCoinAmount > b.HafCoinAmount case "awmDesc": aAmmo := resourceQuantity(a.AssetSummary, "awmAmmo") bAmmo := resourceQuantity(b.AssetSummary, "awmAmmo") if aAmmo != bAmmo { return aAmmo > bAmmo } return a.HafCoinAmount > b.HafCoinAmount case "published", "recommended", "comprehensive", "": return publicRecentLess(a, b) default: return publicRecentLess(a, b) } }) } func publicRecentLess(a ListingDTO, b ListingDTO) bool { aTime := time.Time{} bTime := time.Time{} if a.PublishedAt != nil { aTime = *a.PublishedAt } if b.PublishedAt != nil { bTime = *b.PublishedAt } if !aTime.Equal(bTime) { return aTime.After(bTime) } return a.ID > b.ID } func matchesPublicZone(item ListingDTO, zone string) bool { switch zone { case "", "all": return true case "sale": return item.IsAccelerated case "gift": return hasGiftResourcesSummary(item.AssetSummary) case "night": return isNightAvailableSummary(item.AssetSummary) case "password": return strings.Contains(item.LoginPlatform, "账密") || strings.Contains(item.LoginPlatform, "账号密码") case "highCoin": return coinMFromListing(item) >= 100 default: return true } } func publicZoneCounts(items []ListingDTO) map[string]int64 { counts := map[string]int64{ "all": int64(len(items)), "sale": 0, "gift": 0, "night": 0, "password": 0, "highCoin": 0, } for _, item := range items { for _, zone := range []string{"sale", "gift", "night", "password", "highCoin"} { if matchesPublicZone(item, zone) { counts[zone]++ } } } return counts } func publicSearchText(item ListingDTO) string { parts := []string{ item.ListingNo, strconv.FormatUint(item.ID, 10), strconv.FormatUint(item.AccountID, 10), item.Title, item.Description, item.RankLevel, item.ServerRegion, item.LoginPlatform, } parts = append(parts, assetRegionsFromSummary(item.AssetSummary)...) parts = append(parts, skinNamesFromSummary(item.AssetSummary)...) return strings.ToLower(strings.Join(parts, " ")) } func matchesAny(options []string, value string) bool { if len(options) == 0 { return true } for _, option := range options { if option == value { return true } } return false } func intersects(options []string, values []string) bool { if len(options) == 0 { return true } valueSet := make(map[string]struct{}, len(values)) for _, value := range values { valueSet[value] = struct{}{} } for _, option := range options { if _, ok := valueSet[option]; ok { return true } } return false } func numberInRange(value float64, numberRange NumberRange) bool { if numberRange.Min != nil && value < *numberRange.Min { return false } if numberRange.Max != nil && value > *numberRange.Max { return false } return true } func coinMFromListing(item ListingDTO) float64 { return float64(item.HafCoinAmount) / 1000000 } func readAssetString(summary map[string]any, key string) string { if summary == nil { return "" } value, _ := summary[key].(string) return strings.TrimSpace(value) } func assetRegionsFromSummary(summary map[string]any) []string { if summary == nil { return nil } values, ok := summary["common_regions"].([]any) if !ok { return nil } result := make([]string, 0, len(values)) for _, value := range values { text, ok := value.(string) if ok && strings.TrimSpace(text) != "" { result = append(result, strings.TrimSpace(text)) } } return result } func skinNamesFromSummary(summary map[string]any) []string { groups := skinGroupsFromSummary(summary) result := make([]string, 0) for _, skins := range groups { result = append(result, skins...) } return result } func skinGroupsContainAny(summary map[string]any, groupKeys []string, skinNames []string) bool { groups := skinGroupsFromSummary(summary) for _, groupKey := range groupKeys { if intersects(skinNames, groups[groupKey]) { return true } } return false } func skinGroupsHaveAny(summary map[string]any, groupKeys []string) bool { groups := skinGroupsFromSummary(summary) for _, groupKey := range groupKeys { if len(groups[groupKey]) > 0 { return true } } return false } func skinGroupsFromSummary(summary map[string]any) map[string][]string { result := make(map[string][]string) if summary == nil { return result } rawGroups, ok := summary["skin_groups"].(map[string]any) if !ok { return result } for key, rawSkins := range rawGroups { values, ok := rawSkins.([]any) if !ok { continue } for _, value := range values { text, ok := value.(string) if ok && strings.TrimSpace(text) != "" { result[key] = append(result[key], strings.TrimSpace(text)) } } } return result } func resourceQuantity(summary map[string]any, resourceKey string) float64 { if summary == nil { return 0 } resources, ok := summary["resources"].([]any) if !ok { return 0 } for _, resource := range resources { row, ok := resource.(map[string]any) if !ok { continue } if rowKey, _ := row["key"].(string); rowKey == resourceKey { return readSummaryNumber(row["quantity"]) } } return 0 } func hasGiftResourcesSummary(summary map[string]any) bool { if summary == nil { return false } resources, ok := summary["resources"].([]any) if !ok { return false } for _, resource := range resources { row, ok := resource.(map[string]any) if !ok { continue } if mode, _ := row["mode"].(string); mode == "赠送" && readSummaryNumber(row["quantity"]) > 0 { return true } } return false } func isNightAvailableSummary(summary map[string]any) bool { if summary == nil { return false } onlineTime, ok := summary["online_time"].(map[string]any) if !ok { return false } start, okStart := parseTimeHourValue(onlineTime["start"]) end, okEnd := parseTimeHourValue(onlineTime["end"]) if !okStart || !okEnd { return false } return timeRangeCoversHour(start, end, 22) || timeRangeCoversHour(start, end, 23) || timeRangeCoversHour(start, end, 0) } func parseTimeHourValue(value any) (int, bool) { text, ok := value.(string) if !ok { return 0, false } parts := strings.Split(text, ":") hour, err := strconv.Atoi(parts[0]) if err != nil || hour < 0 || hour > 23 { return 0, false } return hour, true } func timeRangeCoversHour(start int, end int, hour int) bool { if start == end { return true } if start < end { return hour >= start && hour <= end } return hour >= start || hour <= end } 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) { dto, err := r.findDTO("l.id = ? AND l.owner_id = ?", id, ownerID) if err != nil { return nil, err } applySellerListingPrice(dto) return dto, nil } 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 } func (r *Repository) nextListingNo(tx *gorm.DB, now time.Time) (string, error) { bizDate := now.Format("20060102") if tx.Dialector.Name() == "mysql" { if err := tx.Exec(` INSERT INTO listing_no_sequences (biz_date, next_seq) VALUES (?, LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE next_seq = LAST_INSERT_ID(next_seq + 1) `, bizDate).Error; err != nil { return "", err } var seq int if err := tx.Raw("SELECT LAST_INSERT_ID()").Scan(&seq).Error; err != nil { return "", err } return fmt.Sprintf("%s%04d", bizDate, seq), nil } var maxNo string if err := tx.Table("rental_listings"). Select("COALESCE(MAX(listing_no), '')"). Where("listing_no LIKE ?", bizDate+"%"). Scan(&maxNo).Error; err != nil { return "", err } seq := 1 if len(maxNo) > len(bizDate) { if parsed, err := strconv.Atoi(maxNo[len(bizDate):]); err == nil { seq = parsed + 1 } } return fmt.Sprintf("%s%04d", bizDate, seq), 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"` } type publicZoneRow struct { LoginPlatform string HafCoinAmount int64 AssetSummary datatypes.JSON `gorm:"column:asset_summary"` } 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.AssetSummary != nil { if breakdown, ok := req.AssetSummary["price_breakdown"].(map[string]any); ok { buyerPrice := readSummaryNumber(breakdown["buyer_total_price"]) if buyerPrice > 0 { return roundMoney(buyerPrice) } } } return roundMoney(req.Price) } func publicListings(items []ListingDTO) []ListingDTO { for index := range items { applyPublicListingURLs(&items[index]) } return items } func sellerListings(items []ListingDTO) []ListingDTO { for index := range items { applySellerListingPrice(&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 applySellerListingPrice(item *ListingDTO) { if item == nil || item.AssetSummary == nil { return } breakdown, ok := item.AssetSummary["price_breakdown"].(map[string]any) if !ok { return } sellerPrice := readSummaryNumber(breakdown["seller_total_price"]) if sellerPrice > 0 { item.Price = sellerPrice } sellerRatio := readSummaryNumber(breakdown["seller_ratio"]) if sellerRatio > 0 { item.AssetSummary["publish_ratio"] = sellerRatio } delete(breakdown, "buyer_coin_base_price") delete(breakdown, "buyer_total_price") delete(breakdown, "buyer_ratio") delete(breakdown, "platform_markup_amount") delete(breakdown, "platform_rule_type") } func (row listingRow) toDTO() ListingDTO { assetSummary := decodeAssetSummary(row.AssetSummary) screenshotURLS := cleanScreenshotURLs(decodeScreenshots(row.ScreenshotURLS)) reviewStatus, reviewReason := normalizedReviewState(row.Status, row.ReviewStatus, row.ReviewReason) return ListingDTO{ ID: row.ID, ListingNo: row.ListingNo, 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: row.Price, DepositAmount: row.DepositAmount, IsAccelerated: isAcceleratedSale(assetSummary), InTransaction: row.InTransaction, Status: row.Status, ReviewStatus: reviewStatus, ReviewReason: 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)) reviewStatus, reviewReason := normalizedReviewState(listing.Status, listing.ReviewStatus, listing.ReviewReason) return &ListingDTO{ ID: listing.ID, ListingNo: listing.ListingNo, 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: listing.Price, DepositAmount: listing.DepositAmount, IsAccelerated: isAcceleratedSale(assetSummary), InTransaction: listing.InTransaction, Status: listing.Status, ReviewStatus: reviewStatus, ReviewReason: reviewReason, PublishedAt: listing.PublishedAt, CreatedAt: listing.CreatedAt, UpdatedAt: listing.UpdatedAt, } } func normalizedReviewState(status string, reviewStatus string, reviewReason string) (string, string) { if status == "offline" { return "none", reviewReason } return reviewStatus, reviewReason } 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 ensurePriceBreakdown(summary map[string]any) map[string]any { if summary == nil { return map[string]any{} } breakdown, ok := summary["price_breakdown"].(map[string]any) if ok { return breakdown } breakdown = map[string]any{} if raw, ok := summary["price_breakdown"].(map[string]interface{}); ok { for key, value := range raw { breakdown[key] = value } } return breakdown } func calculateAdminAdjustedPrice(req AdminPriceAdjustRequest, coinWan float64, consumablePrice float64) (float64, float64, float64) { if req.BuyerTotalPrice > 0 { buyerTotalPrice := roundMoney(req.BuyerTotalPrice) buyerCoinBasePrice := roundMoney(buyerTotalPrice - consumablePrice) if buyerCoinBasePrice <= 0 || coinWan <= 0 { return 0, 0, 0 } return buyerCoinBasePrice, buyerTotalPrice, roundRatio(coinWan / buyerCoinBasePrice) } if req.BuyerRatio <= 0 || coinWan <= 0 { return 0, 0, 0 } buyerCoinBasePrice := roundMoney(coinWan / req.BuyerRatio) buyerTotalPrice := roundMoney(buyerCoinBasePrice + consumablePrice) return buyerCoinBasePrice, buyerTotalPrice, roundRatio(coinWan / buyerCoinBasePrice) } func roundRatio(value float64) float64 { if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) { return 0 } return math.Round(value*10) / 10 } 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 { return auditlog.Append(tx, auditlog.Entry{ ActorType: "admin", ActorID: actorID, Action: action, BizType: bizType, BizID: &bizID, Meta: meta, Detail: detail, }) } func IsNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) } // roundMoney 使用统一的角精度(0.1元) func roundMoney(value float64) float64 { return money.Round(value) }