Files
hfb_sys/backend/internal/modules/listing/repository.go
T
yml2213andClaude Opus 4.7 4ebf7f75fe refactor: 统一全项目金额精度为角(0.1元)
## 变更概述
将全项目金额处理从整元四舍五入统一为角精度(0.1元),提高金额计算准确性和显示一致性。

## 后端改动
- 新增 pkg/money/format.go 统一金额处理包
  - Round(): 角精度四舍五入
  - Min/Max(): 金额比较
  - Format(): 格式化字符串
- 更新业务模块使用统一金额函数
  - internal/modules/order: 订单结算改为角精度
  - internal/modules/dispute: 纠纷金额处理
  - internal/modules/listing: 商品定价和存储
- 更新测试用例期望值为角精度

## 前端改动
- 新增 shared/utils/money.ts 金额工具函数
  - roundMoney(): 角精度四舍五入
  - formatMoney(): 格式化为字符串(保留1位小数)
  - formatMoneyWithSymbol(): 添加¥符号
- 更新金额计算和显示逻辑
  - shared/utils/pricing.ts: 定价计算
  - shared/utils/listingDisplay.ts: 商品显示
  - shared/composables/useMoney.ts: 组合式函数
- 修复视图文件导入声明
  - features/listings/views: 商品详情页
  - features/seller/views: 卖家管理页

## 效果
- 金额显示:¥123.0(统一保留1位小数)
- 计算精度:12.45 -> 12.5(角精度)
- 减少误差:避免整元四舍五入损失
- 显示一致:全项目统一格式

## 测试
-  后端单元测试通过
-  TypeScript 类型检查通过
-  开发环境正常运行

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 16:42:49 +08:00

1366 lines
36 KiB
Go

package listing
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"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
}
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)
depositAmount := roundMoney(req.DepositAmount)
listing := model.RentalListing{
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 {
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{
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 = ?", "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) (*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) 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(query PublicListQuery) (*PublicListResult, 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").
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))
page := query.Page
if page <= 0 {
page = 1
}
pageSize := query.PageSize
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 50 {
pageSize = 50
}
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) 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.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
}
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.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))
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: row.Price,
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))
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: listing.Price,
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 {
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)
}