package listing import ( "context" "math" "strings" "time" "hfb_sys/backend/internal/listingstatus" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) func (r *Repository) ListPendingReview(ctx context.Context) ([]ListingDTO, error) { var rows []listingRow err := r.baseQuery(ctx). 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) AdminOffline(ctx context.Context, adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) { return r.adminUpdateStatus(ctx, adminID, listingID, req, meta, "offline", "offline", "listing.admin_offline", "商品已被后台下架", "你的租号商品已被后台下架,请查看原因后处理。") } func (r *Repository) AdminMarkAbnormal(ctx context.Context, adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) { return r.adminUpdateStatus(ctx, adminID, listingID, req, meta, "abnormal", "abnormal", "listing.mark_abnormal", "商品已被标记异常", "你的租号商品已被后台标记异常,请联系客服处理。") } func (r *Repository) TransferOwner(ctx context.Context, adminID uint64, listingID uint64, req TransferOwnerRequest, meta AuditMeta) (*ListingDTO, error) { var dto *ListingDTO err := r.db.WithContext(ctx).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 } if listing.OwnerID == req.TargetUserID { dto = toDTO(*account, *listing) return nil } var target model.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("id = ? AND status = ? AND realname_status = ?", req.TargetUserID, "active", "verified"). First(&target).Error; err != nil { if IsNotFound(err) { return ErrTargetOwnerInvalid } return err } beforeOwnerID := listing.OwnerID listing.OwnerID = target.ID listing.HandoffMode = "owner" listing.SettlementMode = "owner_wallet" listing.ManagedAdminID = nil account.OwnerID = target.ID if err := tx.Save(account).Error; err != nil { return err } if err := tx.Save(listing).Error; err != nil { return err } if err := tx.Model(&model.ListingUpload{}). Where("listing_id = ?", listing.ID). Update("owner_id", target.ID).Error; err != nil { return err } if err := transferListingGroupOwner(tx, listing.ID, beforeOwnerID, target.ID); err != nil { return err } if r.chatCreator != nil { if _, err := r.chatCreator.EnsureListingConversation(tx, *listing, 0); err != nil { return err } } if err := appendAuditLog(tx, adminID, "listing.transfer_owner", "listing", listing.ID, meta, map[string]any{ "listing_id": listing.ID, "account_id": account.ID, "before_owner_id": beforeOwnerID, "after_owner_id": target.ID, "reason": req.Reason, }); err != nil { return err } dto = toDTO(*account, *listing) dto.OwnerPhone = target.Phone dto.OwnerNickname = target.Nickname return nil }) return dto, err } func transferListingGroupOwner(tx *gorm.DB, listingID uint64, beforeOwnerID uint64, afterOwnerID uint64) error { var conversation model.ChatConversation err := tx.Where("listing_id = ? AND type = ?", listingID, "listing_group").First(&conversation).Error if err != nil { if IsNotFound(err) { return nil } return err } if err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ? AND role = ?", conversation.ID, "user", beforeOwnerID, "owner"). Delete(&model.ChatParticipant{}).Error; err != nil { return err } participant := model.ChatParticipant{ ConversationID: conversation.ID, ParticipantType: "user", ParticipantID: afterOwnerID, Role: "owner", JoinedAt: time.Now(), } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil { return err } message := model.ChatMessage{ ConversationID: conversation.ID, SenderType: "system", SenderRole: "system", ContentType: "system", Content: "账号归属已由后台转移给新号主", AttachmentURLS: emptyJSONListForListing(), } if err := tx.Create(&message).Error; err != nil { return err } return tx.Model(&model.ChatConversation{}). Where("id = ?", conversation.ID). Updates(map[string]interface{}{ "last_message_id": message.ID, "last_message_preview": message.Content, "last_message_at": message.CreatedAt, }).Error } func emptyJSONListForListing() datatypes.JSON { return datatypes.JSON([]byte("[]")) } func (r *Repository) adminUpdateStatus(ctx context.Context, 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.WithContext(ctx).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 } source := listingstatus.SourceAdmin if listingStatus == "published" { source = listingstatus.SourceReview } if err := listingstatus.AppendTransition(tx, listing, beforeListingStatus, source, listingstatus.ActorAdmin, adminID, req.Reason); 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(ctx context.Context, listingID uint64) (*ListingDTO, error) { var dto *ListingDTO err := r.db.WithContext(ctx).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 } fromStatus := listing.Status 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 } if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceReview, listingstatus.ActorAdmin, 0, "审核通过上架"); 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(ctx context.Context, adminID uint64, listingID uint64, req AdminPriceAdjustRequest, meta AuditMeta) (*ListingDTO, error) { var dto *ListingDTO err := r.db.WithContext(ctx).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, centToYuan(listing.PriceCent)-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 } beforePriceCent := listing.PriceCent beforeRatio := readSummaryNumber(breakdown["buyer_ratio"]) if beforeRatio <= 0 && centToYuan(listing.PriceCent) > consumablePrice { beforeRatio = roundRatio(coinWan / (centToYuan(listing.PriceCent) - consumablePrice)) } listing.PriceCent = yuanToCent(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_cent": beforePriceCent, "after_price_cent": listing.PriceCent, "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(ctx context.Context, listingID uint64, req ReviewRequest) (*ListingDTO, error) { var dto *ListingDTO err := r.db.WithContext(ctx).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: "你的租号发布未通过审核。原因:" + req.Reason + "。请根据原因修改后重新提交。", BizType: "listing", BizID: &listingID, }); err != nil { return err } dto = toDTO(*account, *listing) return nil }) return dto, err }