diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 6afdb74..39d2047 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -16,6 +16,7 @@ type ListingDTO struct { OwnerPhone string `json:"owner_phone,omitempty"` OwnerNickname string `json:"owner_nickname,omitempty"` SourceChannel string `json:"source_channel,omitempty"` + IsExternalUpload bool `json:"is_external_upload"` Title string `json:"title"` Description string `json:"description"` GameName string `json:"game_name"` @@ -70,6 +71,23 @@ type AdminPriceAdjustRequest struct { Reason string `json:"reason"` } +const ( + ExternalAdjustModeDiscountAmount = "discount_amount" + ExternalAdjustModeSaleRatio = "sale_ratio" +) + +type AdminExternalListingAdjustRequest struct { + HafCoinAmount int64 `json:"haf_coin_amount" binding:"required,min=1"` + AWMBullets int `json:"awm_bullets" binding:"min=0,max=1000000"` + Level6Helmets int `json:"level6_helmets" binding:"min=0,max=1000000"` + Level6Armor int `json:"level6_armor" binding:"min=0,max=1000000"` + DepositAmountCent int64 `json:"deposit_amount_cent" binding:"min=0"` + AdjustMode string `json:"adjust_mode" binding:"required,oneof=discount_amount sale_ratio"` + DiscountAmountCent int64 `json:"discount_amount_cent" binding:"min=0"` + SaleRatio float64 `json:"sale_ratio" binding:"min=0"` + Reason string `json:"reason" binding:"required,max=255"` +} + type TransferOwnerRequest struct { TargetUserID uint64 `json:"target_user_id" binding:"required"` Reason string `json:"reason"` diff --git a/backend/internal/modules/listing/external_adjustment.go b/backend/internal/modules/listing/external_adjustment.go new file mode 100644 index 0000000..37b2e70 --- /dev/null +++ b/backend/internal/modules/listing/external_adjustment.go @@ -0,0 +1,275 @@ +package listing + +import ( + "context" + "math" + "strings" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +const maxExternalSaleRatioIncrement = 10.0 + +type externalAdjustmentResult struct { + Resources []any + ReferenceRatio float64 + ReferenceCoinPrice float64 + SaleRatio float64 + SellerCoinPrice float64 + SellerTotalPrice float64 + ConsumablePrice float64 + ActualDiscountAmount float64 + PlatformPricing externalPlatformPricing +} + +func (s *Service) AdjustExternalListing( + ctx context.Context, + adminID uint64, + listingID uint64, + req AdminExternalListingAdjustRequest, + meta AuditMeta, +) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if listingID == 0 || req.HafCoinAmount <= 0 || req.DepositAmountCent < 0 || + req.AWMBullets < 0 || req.Level6Helmets < 0 || req.Level6Armor < 0 || + strings.TrimSpace(req.Reason) == "" { + return nil, ErrExternalAdjustment + } + switch req.AdjustMode { + case ExternalAdjustModeDiscountAmount: + if req.DiscountAmountCent < 0 { + return nil, ErrExternalAdjustment + } + case ExternalAdjustModeSaleRatio: + if req.SaleRatio <= 0 || math.IsNaN(req.SaleRatio) || math.IsInf(req.SaleRatio, 0) { + return nil, ErrExternalAdjustment + } + default: + return nil, ErrExternalAdjustment + } + config, err := s.externalSalePriceConfig(ctx) + if err != nil { + return nil, err + } + return s.repo.AdjustExternalListing(ctx, adminID, listingID, req, config, meta) +} + +func (r *Repository) AdjustExternalListing( + ctx context.Context, + adminID uint64, + listingID uint64, + req AdminExternalListingAdjustRequest, + config salePriceConfig, + meta AuditMeta, +) (*ListingDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + 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.InTransaction || listing.Status == "rented" || listing.Status == "completed" { + return ErrListingLocked + } + switch listing.Status { + case "draft", "published", "offline", "abnormal": + default: + return ErrListingLocked + } + var uploadCount int64 + if err := tx.Model(&model.ListingUpload{}).Where("listing_id = ?", listing.ID).Count(&uploadCount).Error; err != nil { + return err + } + if uploadCount == 0 { + return ErrExternalListingRequired + } + + summary := decodeAssetSummary(account.AssetSummary) + if summary == nil { + summary = map[string]any{} + } + breakdown := ensurePriceBreakdown(summary) + referenceRatio := readSummaryNumber(breakdown["seller_reference_ratio"]) + if referenceRatio <= 0 { + referenceRatio = readSummaryNumber(breakdown["seller_ratio"]) + } + if referenceRatio <= 0 { + return ErrExternalAdjustment + } + + before := externalAdjustmentAuditSnapshot(account, listing, summary, breakdown) + adjustment, err := calculateExternalListingAdjustment(req, summary, referenceRatio, config) + if err != nil { + return err + } + if adjustment.ConsumablePrice > 0 && req.DepositAmountCent <= yuanToCent(adjustment.ConsumablePrice) { + return ErrDepositTooLow + } + + now := time.Now() + summary["resources"] = adjustment.Resources + summary["publish_ratio"] = adjustment.PlatformPricing.BuyerRatio + breakdown["seller_reference_ratio"] = adjustment.ReferenceRatio + breakdown["seller_ratio"] = adjustment.SaleRatio + breakdown["accelerated_sale_ratio"] = adjustment.SaleRatio + breakdown["seller_coin_base_price"] = adjustment.SellerCoinPrice + breakdown["seller_total_price"] = adjustment.SellerTotalPrice + breakdown["consumable_price"] = adjustment.ConsumablePrice + breakdown["buyer_coin_base_price"] = adjustment.PlatformPricing.BuyerCoinBasePrice + breakdown["buyer_total_price"] = adjustment.PlatformPricing.BuyerTotalPrice + breakdown["buyer_ratio"] = adjustment.PlatformPricing.BuyerRatio + breakdown["platform_markup_amount"] = adjustment.PlatformPricing.PlatformMarkupPrice + breakdown["platform_rule_type"] = adjustment.PlatformPricing.RuleType + breakdown["external_admin_adjust_mode"] = req.AdjustMode + breakdown["external_admin_discount_amount"] = adjustment.ActualDiscountAmount + breakdown["external_admin_adjust_reason"] = strings.TrimSpace(req.Reason) + breakdown["external_admin_adjusted_at"] = now.Format(time.RFC3339) + breakdown["external_admin_adjusted_by"] = adminID + summary["price_breakdown"] = breakdown + + assetSummary, err := marshalAssetSummary(summary) + if err != nil { + return err + } + account.HafCoinAmount = req.HafCoinAmount + account.AssetSummary = assetSummary + account.Title = buildExternalListingTitle( + account.RankLevel, + readAssetString(summary, "season_insurance"), + float64(req.HafCoinAmount)/1000000, + account.LoginPlatform, + ) + listing.PriceCent = yuanToCent(adjustment.PlatformPricing.BuyerTotalPrice) + listing.DepositAmountCent = req.DepositAmountCent + if err := tx.Save(account).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + + after := externalAdjustmentAuditSnapshot(account, listing, summary, breakdown) + return appendAuditLog(tx, adminID, "listing.adjust_external", "listing", listing.ID, meta, map[string]any{ + "listing_id": listing.ID, + "account_id": account.ID, + "owner_id": listing.OwnerID, + "adjust_mode": req.AdjustMode, + "requested_discount_amount_cent": req.DiscountAmountCent, + "requested_sale_ratio": req.SaleRatio, + "reason": strings.TrimSpace(req.Reason), + "before": before, + "after": after, + }) + }) + if err != nil { + return nil, err + } + return r.FindAdmin(ctx, listingID) +} + +func calculateExternalListingAdjustment( + req AdminExternalListingAdjustRequest, + summary map[string]any, + referenceRatio float64, + config salePriceConfig, +) (externalAdjustmentResult, error) { + coinM := float64(req.HafCoinAmount) / 1000000 + coinWan := float64(req.HafCoinAmount) / 10000 + referenceCoinPrice := roundMoney(coinWan / referenceRatio) + if coinM <= 0 || coinWan <= 0 || referenceCoinPrice <= 0 { + return externalAdjustmentResult{}, ErrExternalAdjustment + } + + saleRatio := roundRatio(req.SaleRatio) + if req.AdjustMode == ExternalAdjustModeDiscountAmount { + desiredCoinPrice := referenceCoinPrice - centToYuan(req.DiscountAmountCent) + if desiredCoinPrice <= 0 { + return externalAdjustmentResult{}, ErrSaleRatioOutOfRange + } + saleRatio = roundRatio(coinWan / desiredCoinPrice) + } + if saleRatio < referenceRatio || saleRatio > referenceRatio+maxExternalSaleRatioIncrement { + return externalAdjustmentResult{}, ErrSaleRatioOutOfRange + } + sellerCoinPrice := roundMoney(coinWan / saleRatio) + if sellerCoinPrice <= 0 { + return externalAdjustmentResult{}, ErrExternalAdjustment + } + + coreResources := externalResources(ExternalUploadInventory{ + AWMBullets: req.AWMBullets, + Level6Helmets: req.Level6Helmets, + Level6Armor: req.Level6Armor, + }) + resources := mergeExternalCoreResources(summary, coreResources) + consumablePrice := consumableValue(map[string]any{"resources": resources}) + sellerTotalPrice := roundMoney(sellerCoinPrice + consumablePrice) + platformPricing := calculateExternalPlatformPricing( + coinM, + saleRatio, + sellerCoinPrice, + consumablePrice, + config, + ) + if platformPricing.BuyerCoinBasePrice <= 0 || platformPricing.BuyerTotalPrice <= 0 || platformPricing.BuyerRatio <= 0 { + return externalAdjustmentResult{}, ErrInvalidPrice + } + return externalAdjustmentResult{ + Resources: resources, + ReferenceRatio: referenceRatio, + ReferenceCoinPrice: referenceCoinPrice, + SaleRatio: saleRatio, + SellerCoinPrice: sellerCoinPrice, + SellerTotalPrice: sellerTotalPrice, + ConsumablePrice: consumablePrice, + ActualDiscountAmount: roundMoney(referenceCoinPrice - sellerCoinPrice), + PlatformPricing: platformPricing, + }, nil +} + +func mergeExternalCoreResources(summary map[string]any, core []any) []any { + coreKeys := map[string]struct{}{"awmAmmo": {}, "helmet6": {}, "armor6": {}} + result := append([]any{}, core...) + rawResources, _ := summary["resources"].([]any) + for _, raw := range rawResources { + row, ok := raw.(map[string]any) + if !ok { + continue + } + key, _ := row["key"].(string) + if _, isCore := coreKeys[key]; isCore { + continue + } + result = append(result, row) + } + return result +} + +func externalAdjustmentAuditSnapshot( + account *model.GameAccount, + listing *model.RentalListing, + summary map[string]any, + breakdown map[string]any, +) map[string]any { + return map[string]any{ + "haf_coin_amount": account.HafCoinAmount, + "awm_bullets": resourceQuantity(summary, "awmAmmo"), + "level6_helmets": resourceQuantity(summary, "helmet6"), + "level6_armor": resourceQuantity(summary, "armor6"), + "deposit_amount_cent": listing.DepositAmountCent, + "reference_ratio": readSummaryNumber(breakdown["seller_reference_ratio"]), + "sale_ratio": readSummaryNumber(breakdown["seller_ratio"]), + "seller_coin_base_price": readSummaryNumber(breakdown["seller_coin_base_price"]), + "seller_total_price": readSummaryNumber(breakdown["seller_total_price"]), + "buyer_ratio": readSummaryNumber(breakdown["buyer_ratio"]), + "buyer_total_price": readSummaryNumber(breakdown["buyer_total_price"]), + "listing_price_cent": listing.PriceCent, + } +} diff --git a/backend/internal/modules/listing/external_adjustment_test.go b/backend/internal/modules/listing/external_adjustment_test.go new file mode 100644 index 0000000..8e74675 --- /dev/null +++ b/backend/internal/modules/listing/external_adjustment_test.go @@ -0,0 +1,302 @@ +package listing + +import ( + "encoding/json" + "errors" + "math" + "strings" + "testing" + + "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +func TestCalculateExternalListingAdjustmentByDiscountAmount(t *testing.T) { + result, err := calculateExternalListingAdjustment( + AdminExternalListingAdjustRequest{ + HafCoinAmount: 100000000, + AWMBullets: 10, + Level6Helmets: 2, + Level6Armor: 1, + AdjustMode: ExternalAdjustModeDiscountAmount, + DiscountAmountCent: 2500, + }, + map[string]any{}, + 40, + defaultSalePriceConfig(), + ) + if err != nil { + t.Fatalf("按降价金额计算失败: %v", err) + } + + assertExternalAdjustmentNumber(t, "参考纯币价", result.ReferenceCoinPrice, 250) + assertExternalAdjustmentNumber(t, "发布比例", result.SaleRatio, 44.4) + assertExternalAdjustmentNumber(t, "号主纯币价", result.SellerCoinPrice, 225.2) + assertExternalAdjustmentNumber(t, "实际让利", result.ActualDiscountAmount, 24.8) + assertExternalAdjustmentNumber(t, "额外物品", result.ConsumablePrice, 11.5) + assertExternalAdjustmentNumber(t, "号主合计", result.SellerTotalPrice, 236.7) + if result.SaleRatio <= result.ReferenceRatio { + t.Fatalf("降价后应进入特惠状态,参考比例 %.1f,调整比例 %.1f", result.ReferenceRatio, result.SaleRatio) + } +} + +func TestCalculateExternalListingAdjustmentBySaleRatio(t *testing.T) { + result, err := calculateExternalListingAdjustment( + AdminExternalListingAdjustRequest{ + HafCoinAmount: 100000000, + AdjustMode: ExternalAdjustModeSaleRatio, + SaleRatio: 45, + }, + map[string]any{}, + 40, + defaultSalePriceConfig(), + ) + if err != nil { + t.Fatalf("按发布比例计算失败: %v", err) + } + + assertExternalAdjustmentNumber(t, "发布比例", result.SaleRatio, 45) + assertExternalAdjustmentNumber(t, "号主纯币价", result.SellerCoinPrice, 222.2) + assertExternalAdjustmentNumber(t, "租客比例", result.PlatformPricing.BuyerRatio, 40) + assertExternalAdjustmentNumber(t, "租客售价", result.PlatformPricing.BuyerTotalPrice, 250) +} + +func TestAdjustExternalListingSynchronizesAssetsPricesAndAudit(t *testing.T) { + db, service, account, listing := newExternalAdjustmentFixture(t, "published", false, true) + + updated, err := service.AdjustExternalListing(t.Context(), 88, listing.ID, AdminExternalListingAdjustRequest{ + HafCoinAmount: 120000000, + AWMBullets: 10, + Level6Helmets: 2, + Level6Armor: 3, + DepositAmountCent: 50000, + AdjustMode: ExternalAdjustModeDiscountAmount, + DiscountAmountCent: 2000, + Reason: "用户申请加入特惠", + }, AuditMeta{IP: "127.0.0.1", UserAgent: "adjustment-test", RequestID: "request-1"}) + if err != nil { + t.Fatalf("调整外部商品失败: %v", err) + } + if !updated.IsExternalUpload { + t.Fatal("后台详情应标记为外部上传商品") + } + if !updated.IsAccelerated { + t.Fatal("调整后应展示特惠标签") + } + if updated.SourceChannel != "淘宝" { + t.Fatalf("来源渠道 = %q,期望 淘宝", updated.SourceChannel) + } + if updated.PriceCent != 33310 || updated.DepositAmountCent != 50000 { + t.Fatalf("商品价格/押金 = %d/%d,期望 33310/50000", updated.PriceCent, updated.DepositAmountCent) + } + + var storedAccount model.GameAccount + if err := db.First(&storedAccount, account.ID).Error; err != nil { + t.Fatalf("查询账号失败: %v", err) + } + if storedAccount.HafCoinAmount != 120000000 { + t.Fatalf("纯币数量 = %d,期望 120000000", storedAccount.HafCoinAmount) + } + if !strings.Contains(storedAccount.Title, "120.0M") { + t.Fatalf("商品标题未同步纯币数量: %q", storedAccount.Title) + } + summary := decodeAssetSummary(storedAccount.AssetSummary) + if resourceQuantity(summary, "awmAmmo") != 10 || + resourceQuantity(summary, "helmet6") != 2 || + resourceQuantity(summary, "armor6") != 3 { + t.Fatalf("额外物品数量未同步: %#v", summary["resources"]) + } + if resourceQuantity(summary, "giftMed") != 4 { + t.Fatalf("非核心物品应保留: %#v", summary["resources"]) + } + breakdown := ensurePriceBreakdown(summary) + assertExternalAdjustmentNumber(t, "账号发布比例", readSummaryNumber(breakdown["seller_ratio"]), 42.9) + assertExternalAdjustmentNumber(t, "号主纯币价", readSummaryNumber(breakdown["seller_coin_base_price"]), 279.7) + assertExternalAdjustmentNumber(t, "额外物品价格", readSummaryNumber(breakdown["consumable_price"]), 16.5) + assertExternalAdjustmentNumber(t, "号主合计", readSummaryNumber(breakdown["seller_total_price"]), 296.2) + assertExternalAdjustmentNumber(t, "租客比例", readSummaryNumber(breakdown["buyer_ratio"]), 37.9) + assertExternalAdjustmentNumber(t, "租客售价", readSummaryNumber(breakdown["buyer_total_price"]), 333.1) + if breakdown["external_admin_adjust_reason"] != "用户申请加入特惠" { + t.Fatalf("调整原因未写入价格快照: %#v", breakdown["external_admin_adjust_reason"]) + } + + var audit model.AuditLog + if err := db.Where("action = ?", "listing.adjust_external").First(&audit).Error; err != nil { + t.Fatalf("查询审计日志失败: %v", err) + } + if audit.ActorID != 88 || audit.IP != "127.0.0.1" || audit.UserAgent != "adjustment-test" { + t.Fatalf("审计操作人或请求信息不正确: %#v", audit) + } + var detail map[string]any + if err := json.Unmarshal(audit.Detail, &detail); err != nil { + t.Fatalf("解析审计详情失败: %v", err) + } + before, _ := detail["before"].(map[string]any) + after, _ := detail["after"].(map[string]any) + assertExternalAdjustmentNumber(t, "审计调整前纯币", readSummaryNumber(before["haf_coin_amount"]), 100000000) + assertExternalAdjustmentNumber(t, "审计调整后纯币", readSummaryNumber(after["haf_coin_amount"]), 120000000) + assertExternalAdjustmentNumber(t, "审计调整后比例", readSummaryNumber(after["sale_ratio"]), 42.9) + assertExternalAdjustmentNumber(t, "审计调整后售价", readSummaryNumber(after["listing_price_cent"]), 33310) + if detail["request_id"] != "request-1" { + t.Fatalf("审计 request_id = %#v,期望 request-1", detail["request_id"]) + } +} + +func TestAdjustExternalListingRejectsWebsiteListing(t *testing.T) { + _, service, _, listing := newExternalAdjustmentFixture(t, "published", false, false) + + _, err := service.AdjustExternalListing(t.Context(), 1, listing.ID, validExternalAdjustmentRequest(), AuditMeta{}) + if !errors.Is(err, ErrExternalListingRequired) { + t.Fatalf("普通站内商品应返回 ErrExternalListingRequired,实际 %v", err) + } +} + +func TestAdjustExternalListingRejectsLockedStates(t *testing.T) { + tests := []struct { + name string + status string + inTransaction bool + }{ + {name: "交易锁定", status: "published", inTransaction: true}, + {name: "租用中", status: "rented"}, + {name: "已完成", status: "completed"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, service, _, listing := newExternalAdjustmentFixture(t, tt.status, tt.inTransaction, true) + _, err := service.AdjustExternalListing(t.Context(), 1, listing.ID, validExternalAdjustmentRequest(), AuditMeta{}) + if !errors.Is(err, ErrListingLocked) { + t.Fatalf("应返回 ErrListingLocked,实际 %v", err) + } + }) + } +} + +func TestAdjustExternalListingRejectsRatioAboveLimit(t *testing.T) { + _, service, _, listing := newExternalAdjustmentFixture(t, "published", false, true) + req := validExternalAdjustmentRequest() + req.AdjustMode = ExternalAdjustModeSaleRatio + req.SaleRatio = 50.1 + + _, err := service.AdjustExternalListing(t.Context(), 1, listing.ID, req, AuditMeta{}) + if !errors.Is(err, ErrSaleRatioOutOfRange) { + t.Fatalf("超过参考比例加 10 应返回 ErrSaleRatioOutOfRange,实际 %v", err) + } +} + +func TestAdjustExternalListingRejectsDepositNotAboveResources(t *testing.T) { + _, service, _, listing := newExternalAdjustmentFixture(t, "published", false, true) + req := validExternalAdjustmentRequest() + req.AWMBullets = 10 + req.DepositAmountCent = 600 + + _, err := service.AdjustExternalListing(t.Context(), 1, listing.ID, req, AuditMeta{}) + if !errors.Is(err, ErrDepositTooLow) { + t.Fatalf("押金不高于物品价值应返回 ErrDepositTooLow,实际 %v", err) + } +} + +func newExternalAdjustmentFixture( + t *testing.T, + status string, + inTransaction bool, + external bool, +) (*gorm.DB, *Service, model.GameAccount, model.RentalListing) { + t.Helper() + db := database.NewTestDB() + if err := database.MigrateListingLifecycleTestSchema(db); err != nil { + t.Fatalf("初始化商品测试表失败: %v", err) + } + if err := db.AutoMigrate(&model.ListingUpload{}, &model.AuditLog{}); err != nil { + t.Fatalf("初始化外部商品测试表失败: %v", err) + } + user := model.User{Phone: "18800000009", Nickname: "测试号主", RealnameStatus: "verified", Status: "active"} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("创建测试号主失败: %v", err) + } + createReq := externalAccountToCreateRequest("客服1", 0, ExternalAccountData{ + LoginMethod: "QQ账号密码", + Rank: "黑鹰", + SafeSlots: 9, + Deposit: 500, + Currency: ExternalUploadCurrency{ + HafuCoin: 100, + RecycleRatio: 40, + RecycleRent: 250, + }, + DailyConsumption: ExternalDailyConsumption{Stamina: 7, Weight: 7}, + Inventory: ExternalUploadInventory{ + AWMBullets: 5, + Level6Helmets: 1, + Level6Armor: 1, + }, + }, defaultSalePriceConfig()) + createReq.AssetSummary["resources"] = append( + createReq.AssetSummary["resources"].([]any), + map[string]any{"key": "giftMed", "label": "赠送医疗物资", "price": "1元/个", "quantity": 4, "mode": "赠送"}, + ) + assetSummary, err := marshalAssetSummary(createReq.AssetSummary) + if err != nil { + t.Fatalf("编码资产摘要失败: %v", err) + } + account := model.GameAccount{ + OwnerID: user.ID, + GameName: "delta_force", + ServerRegion: createReq.ServerRegion, + LoginPlatform: createReq.LoginPlatform, + Title: createReq.Title, + RankLevel: createReq.RankLevel, + HafCoinAmount: createReq.HafCoinAmount, + AssetSummary: assetSummary, + Status: status, + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("创建测试账号失败: %v", err) + } + listing := model.RentalListing{ + ListingNo: "202607260001", + AccountID: account.ID, + OwnerID: user.ID, + PriceCent: createReq.PriceCent, + DepositAmountCent: createReq.DepositAmountCent, + InTransaction: inTransaction, + Status: status, + ReviewStatus: "approved", + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("创建测试商品失败: %v", err) + } + if external { + listingID := listing.ID + ownerID := user.ID + if err := db.Create(&model.ListingUpload{ + UploaderName: "客服1", + SourceChannel: "淘宝", + OwnerID: &ownerID, + ListingID: &listingID, + Status: "draft_created", + }).Error; err != nil { + t.Fatalf("创建外部上传记录失败: %v", err) + } + } + return db, NewService(NewRepository(db, nil), nil), account, listing +} + +func validExternalAdjustmentRequest() AdminExternalListingAdjustRequest { + return AdminExternalListingAdjustRequest{ + HafCoinAmount: 100000000, + DepositAmountCent: 50000, + AdjustMode: ExternalAdjustModeSaleRatio, + SaleRatio: 40, + Reason: "修正商品信息", + } +} + +func assertExternalAdjustmentNumber(t *testing.T, field string, actual float64, expected float64) { + t.Helper() + if math.Abs(actual-expected) > 0.001 { + t.Fatalf("%s = %.4f,期望 %.4f", field, actual, expected) + } +} diff --git a/backend/internal/modules/listing/external_upload.go b/backend/internal/modules/listing/external_upload.go index 3394faf..a812db0 100644 --- a/backend/internal/modules/listing/external_upload.go +++ b/backend/internal/modules/listing/external_upload.go @@ -360,11 +360,15 @@ func normalizeExternalTimePart(hourText, minuteText string) (string, bool) { } func externalUploadTitle(item ExternalAccountData, insurance string, hafCoinM float64) string { + return buildExternalListingTitle(item.Rank, insurance, hafCoinM, item.LoginMethod) +} + +func buildExternalListingTitle(rank string, insurance string, hafCoinM float64, loginMethod string) string { parts := []string{ - strings.TrimSpace(item.Rank), + strings.TrimSpace(rank), insurance, fmt.Sprintf("%.1fM", hafCoinM), - strings.TrimSpace(item.LoginMethod), + strings.TrimSpace(loginMethod), } title := strings.TrimSpace(strings.Join(cleanStrings(parts), " ")) if title == "" { diff --git a/backend/internal/modules/listing/handler_admin.go b/backend/internal/modules/listing/handler_admin.go index 0817ed5..b66f750 100644 --- a/backend/internal/modules/listing/handler_admin.go +++ b/backend/internal/modules/listing/handler_admin.go @@ -142,6 +142,29 @@ func (h *Handler) AdjustReviewPrice(c *gin.Context) { response.OK(c, item) } +func (h *Handler) AdjustExternalListing(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req AdminExternalListingAdjustRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "外部商品调整参数不正确") + return + } + item, err := h.service.AdjustExternalListing(c.Request.Context(), adminID, id, req, auditMeta(c)) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + func (h *Handler) Reject(c *gin.Context) { id, ok := parseID(c) if !ok { diff --git a/backend/internal/modules/listing/handler_error.go b/backend/internal/modules/listing/handler_error.go index 8acf673..8f767d5 100644 --- a/backend/internal/modules/listing/handler_error.go +++ b/backend/internal/modules/listing/handler_error.go @@ -48,6 +48,12 @@ func writeListingError(c *gin.Context, err error) { response.BadRequest(c, "上传人名称匹配到多个后台用户,请使用唯一用户名") case errors.Is(err, ErrTooManyUploadItems): response.BadRequest(c, "单次上传账号数量过多") + case errors.Is(err, ErrExternalListingRequired): + response.BadRequest(c, "仅支持修改外部 API 上传的商品") + case errors.Is(err, ErrExternalAdjustment): + response.BadRequest(c, "外部商品调整参数不正确") + case errors.Is(err, ErrSaleRatioOutOfRange): + response.BadRequest(c, "发布比例必须在参考比例至参考比例加 10 之间") case isUploadValidationError(err): response.BadRequest(c, err.Error()) case isFireLevelTooLow(err): diff --git a/backend/internal/modules/listing/presenter.go b/backend/internal/modules/listing/presenter.go index fd009e6..ba47c6e 100644 --- a/backend/internal/modules/listing/presenter.go +++ b/backend/internal/modules/listing/presenter.go @@ -15,18 +15,19 @@ import ( type listingRow struct { model.RentalListing - Title string - OwnerPhone string - OwnerNickname string - SourceChannel string `gorm:"column:source_channel"` - 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"` + Title string + OwnerPhone string + OwnerNickname string + SourceChannel string `gorm:"column:source_channel"` + IsExternalUpload bool `gorm:"column:is_external_upload"` + 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 { @@ -65,6 +66,7 @@ func sellerListings(items []ListingDTO) []ListingDTO { func applyPublicListingURLs(item *ListingDTO) { item.SourceChannel = "" + item.IsExternalUpload = false item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus) if item.AssetSummary != nil { delete(item.AssetSummary, "import_meta") @@ -77,6 +79,7 @@ func applySellerListingPrice(item *ListingDTO) { return } item.SourceChannel = "" + item.IsExternalUpload = false if item.AssetSummary == nil { return } @@ -111,6 +114,7 @@ func (row listingRow) toDTO() ListingDTO { OwnerPhone: row.OwnerPhone, OwnerNickname: row.OwnerNickname, SourceChannel: row.SourceChannel, + IsExternalUpload: row.IsExternalUpload, Title: row.Title, Description: row.Description, GameName: row.GameName, diff --git a/backend/internal/modules/listing/query.go b/backend/internal/modules/listing/query.go index 626786e..f064fcc 100644 --- a/backend/internal/modules/listing/query.go +++ b/backend/internal/modules/listing/query.go @@ -211,6 +211,7 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { return r.db.WithContext(ctx).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, + CASE WHEN lu.upload_id IS NULL THEN 0 ELSE 1 END AS is_external_upload, CASE WHEN lu.upload_id IS NULL THEN ? WHEN COALESCE(lu.source_channel, '') = '' THEN ? diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index a07dc06..9c63393 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -29,6 +29,9 @@ var ( ErrUploaderNotFound = errors.New("uploader not found") ErrUploaderAmbiguous = errors.New("uploader ambiguous") ErrTooManyUploadItems = errors.New("too many upload items") + ErrExternalListingRequired = errors.New("external listing required") + ErrExternalAdjustment = errors.New("invalid external listing adjustment") + ErrSaleRatioOutOfRange = errors.New("sale ratio out of range") ) type Service struct { diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 0b13e9c..487d58c 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -606,6 +606,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin) adminRoutes.POST("/listings/:id/approve", requirePerm("listing:approve"), listingHandler.Approve) adminRoutes.POST("/listings/:id/adjust-price", requirePerm("listing:approve"), listingHandler.AdjustReviewPrice) + adminRoutes.PUT("/listings/:id/external-adjustment", requirePerm("listing:edit_external"), listingHandler.AdjustExternalListing) adminRoutes.POST("/listings/:id/transfer-owner", requirePerm("listing:approve"), listingHandler.TransferOwner) adminRoutes.POST("/listings/:id/reject", requirePerm("listing:reject"), listingHandler.Reject) adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline) diff --git a/backend/migrations/000042_external_listing_adjustment.sql b/backend/migrations/000042_external_listing_adjustment.sql new file mode 100644 index 0000000..9a2ae8b --- /dev/null +++ b/backend/migrations/000042_external_listing_adjustment.sql @@ -0,0 +1,29 @@ +-- +goose Up + +INSERT INTO permissions (code, name, resource, action) VALUES +('listing:edit_external', '修改外部上传商品', 'listing', 'edit_external') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code IN ('super_admin', 'ops', 'cs') AND p.code = 'listing:edit_external'; + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.code = 'cs' AND p.code = 'listing:view'; + +-- +goose Down + +DELETE rp FROM role_permissions rp +JOIN roles r ON r.id = rp.role_id +JOIN permissions p ON p.id = rp.permission_id +WHERE r.code = 'cs' AND p.code = 'listing:view'; + +DELETE rp FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'listing:edit_external'; + +DELETE FROM permissions WHERE code = 'listing:edit_external'; diff --git a/frontend/src/features/admin/views/AdminListingDetailView.vue b/frontend/src/features/admin/views/AdminListingDetailView.vue index 90ff04f..59644a3 100644 --- a/frontend/src/features/admin/views/AdminListingDetailView.vue +++ b/frontend/src/features/admin/views/AdminListingDetailView.vue @@ -1,22 +1,34 @@