package listing import ( "encoding/json" "errors" "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 (r *Repository) Create(ownerID uint64, req CreateRequest) (*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 } 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: "draft", } if err := tx.Create(&account).Error; err != nil { return err } listing := model.RentalListing{ AccountID: account.ID, OwnerID: ownerID, PriceHourly: req.PriceHourly, PriceDaily: req.PriceDaily, PriceWeekly: req.PriceWeekly, DepositAmount: req.DepositAmount, MinRentHours: req.MinRentHours, MaxRentHours: req.MaxRentHours, Status: "draft", ReviewStatus: "none", } 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) (*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" { 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 if err := tx.Save(account).Error; err != nil { return err } listing.PriceHourly = req.PriceHourly listing.PriceDaily = req.PriceDaily listing.PriceWeekly = req.PriceWeekly listing.DepositAmount = req.DepositAmount listing.MinRentHours = req.MinRentHours listing.MaxRentHours = req.MaxRentHours listing.Status = "draft" listing.ReviewStatus = "none" listing.ReviewReason = "" listing.PublishedAt = nil 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) (*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" { return ErrListingLocked } listing.Status = "draft" listing.ReviewStatus = "pending" listing.ReviewReason = "" 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 } 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" { 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" { 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" { 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" { 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 = ?", "published", "approved"). Order("l.published_at DESC, l.id DESC"). Limit(100). Scan(&rows).Error if err != nil { return nil, err } return 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) { return r.findDTO("l.id = ? AND l.status = ? AND l.review_status = ?", id, "published", "approved") } 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 ScreenshotURLS datatypes.JSON } func rowsToDTO(rows []listingRow) []ListingDTO { items := make([]ListingDTO, 0, len(rows)) for _, row := range rows { items = append(items, row.toDTO()) } return items } func (row listingRow) toDTO() ListingDTO { 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: decodeAssetSummary(row.AssetSummary), ScreenshotURLS: decodeScreenshots(row.ScreenshotURLS), PriceHourly: row.PriceHourly, PriceDaily: row.PriceDaily, PriceWeekly: row.PriceWeekly, DepositAmount: row.DepositAmount, MinRentHours: row.MinRentHours, MaxRentHours: row.MaxRentHours, 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 { 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: decodeAssetSummary(account.AssetSummary), ScreenshotURLS: decodeScreenshots(account.ScreenshotURLS), PriceHourly: listing.PriceHourly, PriceDaily: listing.PriceDaily, PriceWeekly: listing.PriceWeekly, DepositAmount: listing.DepositAmount, MinRentHours: listing.MinRentHours, MaxRentHours: listing.MaxRentHours, 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 := 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) if len(cleaned) >= 12 { break } } 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 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) }