实现外部商品调价
This commit is contained in:
@@ -16,6 +16,7 @@ type ListingDTO struct {
|
|||||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||||
OwnerNickname string `json:"owner_nickname,omitempty"`
|
OwnerNickname string `json:"owner_nickname,omitempty"`
|
||||||
SourceChannel string `json:"source_channel,omitempty"`
|
SourceChannel string `json:"source_channel,omitempty"`
|
||||||
|
IsExternalUpload bool `json:"is_external_upload"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
GameName string `json:"game_name"`
|
GameName string `json:"game_name"`
|
||||||
@@ -70,6 +71,23 @@ type AdminPriceAdjustRequest struct {
|
|||||||
Reason string `json:"reason"`
|
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 {
|
type TransferOwnerRequest struct {
|
||||||
TargetUserID uint64 `json:"target_user_id" binding:"required"`
|
TargetUserID uint64 `json:"target_user_id" binding:"required"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -360,11 +360,15 @@ func normalizeExternalTimePart(hourText, minuteText string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func externalUploadTitle(item ExternalAccountData, insurance string, hafCoinM float64) string {
|
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{
|
parts := []string{
|
||||||
strings.TrimSpace(item.Rank),
|
strings.TrimSpace(rank),
|
||||||
insurance,
|
insurance,
|
||||||
fmt.Sprintf("%.1fM", hafCoinM),
|
fmt.Sprintf("%.1fM", hafCoinM),
|
||||||
strings.TrimSpace(item.LoginMethod),
|
strings.TrimSpace(loginMethod),
|
||||||
}
|
}
|
||||||
title := strings.TrimSpace(strings.Join(cleanStrings(parts), " "))
|
title := strings.TrimSpace(strings.Join(cleanStrings(parts), " "))
|
||||||
if title == "" {
|
if title == "" {
|
||||||
|
|||||||
@@ -142,6 +142,29 @@ func (h *Handler) AdjustReviewPrice(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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) {
|
func (h *Handler) Reject(c *gin.Context) {
|
||||||
id, ok := parseID(c)
|
id, ok := parseID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ func writeListingError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "上传人名称匹配到多个后台用户,请使用唯一用户名")
|
response.BadRequest(c, "上传人名称匹配到多个后台用户,请使用唯一用户名")
|
||||||
case errors.Is(err, ErrTooManyUploadItems):
|
case errors.Is(err, ErrTooManyUploadItems):
|
||||||
response.BadRequest(c, "单次上传账号数量过多")
|
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):
|
case isUploadValidationError(err):
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
case isFireLevelTooLow(err):
|
case isFireLevelTooLow(err):
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type listingRow struct {
|
|||||||
OwnerPhone string
|
OwnerPhone string
|
||||||
OwnerNickname string
|
OwnerNickname string
|
||||||
SourceChannel string `gorm:"column:source_channel"`
|
SourceChannel string `gorm:"column:source_channel"`
|
||||||
|
IsExternalUpload bool `gorm:"column:is_external_upload"`
|
||||||
Description string
|
Description string
|
||||||
GameName string
|
GameName string
|
||||||
ServerRegion string
|
ServerRegion string
|
||||||
@@ -65,6 +66,7 @@ func sellerListings(items []ListingDTO) []ListingDTO {
|
|||||||
|
|
||||||
func applyPublicListingURLs(item *ListingDTO) {
|
func applyPublicListingURLs(item *ListingDTO) {
|
||||||
item.SourceChannel = ""
|
item.SourceChannel = ""
|
||||||
|
item.IsExternalUpload = false
|
||||||
item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus)
|
item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus)
|
||||||
if item.AssetSummary != nil {
|
if item.AssetSummary != nil {
|
||||||
delete(item.AssetSummary, "import_meta")
|
delete(item.AssetSummary, "import_meta")
|
||||||
@@ -77,6 +79,7 @@ func applySellerListingPrice(item *ListingDTO) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
item.SourceChannel = ""
|
item.SourceChannel = ""
|
||||||
|
item.IsExternalUpload = false
|
||||||
if item.AssetSummary == nil {
|
if item.AssetSummary == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -111,6 +114,7 @@ func (row listingRow) toDTO() ListingDTO {
|
|||||||
OwnerPhone: row.OwnerPhone,
|
OwnerPhone: row.OwnerPhone,
|
||||||
OwnerNickname: row.OwnerNickname,
|
OwnerNickname: row.OwnerNickname,
|
||||||
SourceChannel: row.SourceChannel,
|
SourceChannel: row.SourceChannel,
|
||||||
|
IsExternalUpload: row.IsExternalUpload,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
Description: row.Description,
|
Description: row.Description,
|
||||||
GameName: row.GameName,
|
GameName: row.GameName,
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
|||||||
return r.db.WithContext(ctx).Table("rental_listings AS l").
|
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,
|
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,
|
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
|
CASE
|
||||||
WHEN lu.upload_id IS NULL THEN ?
|
WHEN lu.upload_id IS NULL THEN ?
|
||||||
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
|
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ var (
|
|||||||
ErrUploaderNotFound = errors.New("uploader not found")
|
ErrUploaderNotFound = errors.New("uploader not found")
|
||||||
ErrUploaderAmbiguous = errors.New("uploader ambiguous")
|
ErrUploaderAmbiguous = errors.New("uploader ambiguous")
|
||||||
ErrTooManyUploadItems = errors.New("too many upload items")
|
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 {
|
type Service struct {
|
||||||
|
|||||||
@@ -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.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin)
|
||||||
adminRoutes.POST("/listings/:id/approve", requirePerm("listing:approve"), listingHandler.Approve)
|
adminRoutes.POST("/listings/:id/approve", requirePerm("listing:approve"), listingHandler.Approve)
|
||||||
adminRoutes.POST("/listings/:id/adjust-price", requirePerm("listing:approve"), listingHandler.AdjustReviewPrice)
|
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/transfer-owner", requirePerm("listing:approve"), listingHandler.TransferOwner)
|
||||||
adminRoutes.POST("/listings/:id/reject", requirePerm("listing:reject"), listingHandler.Reject)
|
adminRoutes.POST("/listings/:id/reject", requirePerm("listing:reject"), listingHandler.Reject)
|
||||||
adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline)
|
adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline)
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -1,22 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { readError } from '@/shared/utils/error'
|
import { readError } from '@/shared/utils/error'
|
||||||
import { CopyDocument, Search } from '@element-plus/icons-vue'
|
import { CopyDocument, EditPen, Search } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { fetchAdminUsers, type AdminUserItem } from '@/features/admin/api/adminUsers'
|
import { fetchAdminUsers, type AdminUserItem } from '@/features/admin/api/adminUsers'
|
||||||
import { fetchAdminFileBlob } from '@/shared/api/files'
|
import { fetchAdminFileBlob } from '@/shared/api/files'
|
||||||
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
import AuthImage from '@/shared/components/business/AuthImage.vue'
|
||||||
import { adminPath } from '@/shared/utils/adminPath'
|
import { adminPath } from '@/shared/utils/adminPath'
|
||||||
import { formatCentWithSymbol, formatMoneyWithSymbol, roundMoney } from '@/shared/utils/money'
|
|
||||||
import {
|
import {
|
||||||
|
centToYuan,
|
||||||
|
formatCentWithSymbol,
|
||||||
|
formatMoneyWithSymbol,
|
||||||
|
roundMoney,
|
||||||
|
yuanToCent,
|
||||||
|
} from '@/shared/utils/money'
|
||||||
|
import {
|
||||||
|
adjustExternalListing,
|
||||||
adminMarkListingAbnormal,
|
adminMarkListingAbnormal,
|
||||||
adminOfflineListing,
|
adminOfflineListing,
|
||||||
adminTransferListingOwner,
|
adminTransferListingOwner,
|
||||||
|
emptyListingSalePriceConfig,
|
||||||
fetchAdminListing,
|
fetchAdminListing,
|
||||||
|
fetchListingSalePriceConfig,
|
||||||
|
type AdminExternalListingAdjustMode,
|
||||||
|
type PublishSalePriceConfig,
|
||||||
type Listing,
|
type Listing,
|
||||||
} from '@/features/listings'
|
} from '@/features/listings'
|
||||||
|
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||||
import {
|
import {
|
||||||
listingReviewStatusLabel,
|
listingReviewStatusLabel,
|
||||||
listingStatusLabel,
|
listingStatusLabel,
|
||||||
@@ -42,6 +54,9 @@ import {
|
|||||||
readAssetNumber,
|
readAssetNumber,
|
||||||
readAssetString,
|
readAssetString,
|
||||||
} from '@/shared/utils/listingDisplay'
|
} from '@/shared/utils/listingDisplay'
|
||||||
|
import { calculatePlatformPricing, roundRatio as roundPricingRatio } from '@/shared/utils/pricing'
|
||||||
|
|
||||||
|
const adminSession = useAdminSessionStore()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -55,6 +70,24 @@ const transferReason = ref('')
|
|||||||
const userKeyword = ref('')
|
const userKeyword = ref('')
|
||||||
const userOptions = ref<AdminUserItem[]>([])
|
const userOptions = ref<AdminUserItem[]>([])
|
||||||
const selectedUser = ref<AdminUserItem | null>(null)
|
const selectedUser = ref<AdminUserItem | null>(null)
|
||||||
|
const externalAdjustVisible = ref(false)
|
||||||
|
const externalAdjustSaving = ref(false)
|
||||||
|
const salePriceConfig = ref<PublishSalePriceConfig>(emptyListingSalePriceConfig)
|
||||||
|
const externalAdjustModes: Array<{ label: string; value: AdminExternalListingAdjustMode }> = [
|
||||||
|
{ label: '按降价金额', value: 'discount_amount' },
|
||||||
|
{ label: '按发布比例', value: 'sale_ratio' },
|
||||||
|
]
|
||||||
|
const externalAdjustForm = reactive({
|
||||||
|
haf_coin_m: 0,
|
||||||
|
awm_bullets: 0,
|
||||||
|
level6_helmets: 0,
|
||||||
|
level6_armor: 0,
|
||||||
|
deposit_yuan: 0,
|
||||||
|
adjust_mode: 'discount_amount' as AdminExternalListingAdjustMode,
|
||||||
|
discount_amount_yuan: 0,
|
||||||
|
sale_ratio: 0,
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
const actionTitle = computed(() =>
|
const actionTitle = computed(() =>
|
||||||
actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'
|
actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'
|
||||||
@@ -66,6 +99,15 @@ const canOperate = computed(
|
|||||||
!['offline', 'abnormal'].includes(listing.value.status)
|
!['offline', 'abnormal'].includes(listing.value.status)
|
||||||
)
|
)
|
||||||
const canTransfer = computed(() => !!listing.value && listing.value.status !== 'rented')
|
const canTransfer = computed(() => !!listing.value && listing.value.status !== 'rented')
|
||||||
|
const canAdjustExternal = computed(
|
||||||
|
() => !!listing.value?.is_external_upload && adminSession.hasPermission('listing:edit_external')
|
||||||
|
)
|
||||||
|
const externalAdjustmentLocked = computed(
|
||||||
|
() =>
|
||||||
|
!listing.value ||
|
||||||
|
listing.value.in_transaction ||
|
||||||
|
!['draft', 'published', 'offline', 'abnormal'].includes(listing.value.status)
|
||||||
|
)
|
||||||
const selectedUserAvailable = computed(
|
const selectedUserAvailable = computed(
|
||||||
() => selectedUser.value?.status === 'active' && selectedUser.value.realname_status === 'verified'
|
() => selectedUser.value?.status === 'active' && selectedUser.value.realname_status === 'verified'
|
||||||
)
|
)
|
||||||
@@ -109,6 +151,14 @@ const ownerRows = computed(() => {
|
|||||||
{ label: '号主损耗', value: moneyYuan(ownerLoss) },
|
{ label: '号主损耗', value: moneyYuan(ownerLoss) },
|
||||||
{ label: '号主租金合计', value: moneyYuan(ownerTotal) },
|
{ label: '号主租金合计', value: moneyYuan(ownerTotal) },
|
||||||
{ label: '平台费用', value: moneyYuan(platformFee) },
|
{ label: '平台费用', value: moneyYuan(platformFee) },
|
||||||
|
...(row.is_external_upload
|
||||||
|
? [
|
||||||
|
{ label: 'API参考比例', value: ratioText(externalReferenceRatio.value) },
|
||||||
|
{ label: '号主发布比例', value: ratioText(sellerSaleRatio(row)) },
|
||||||
|
{ label: '租客售卖比例', value: ratioText(buyerSaleRatio(row)) },
|
||||||
|
{ label: '价格标签', value: row.is_accelerated_sale ? '特惠' : '常规' },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{ label: '押金', value: moneyCent(row.deposit_amount_cent) },
|
{ label: '押金', value: moneyCent(row.deposit_amount_cent) },
|
||||||
{ label: '哈夫币', value: formatHafCoinM(getCoinWan(row)) },
|
{ label: '哈夫币', value: formatHafCoinM(getCoinWan(row)) },
|
||||||
{ label: '每日损耗', value: getDailyLoss(row) || '-' },
|
{ label: '每日损耗', value: getDailyLoss(row) || '-' },
|
||||||
@@ -133,6 +183,68 @@ const hiddenSkinCount = computed(() => {
|
|||||||
if (!listing.value) return 0
|
if (!listing.value) return 0
|
||||||
return Math.max(0, getSkinNames(listing.value).length - skinNames.value.length)
|
return Math.max(0, getSkinNames(listing.value).length - skinNames.value.length)
|
||||||
})
|
})
|
||||||
|
const externalReferenceRatio = computed(() => {
|
||||||
|
if (!listing.value) return 0
|
||||||
|
return (
|
||||||
|
breakdownNumber(listing.value, 'seller_reference_ratio') ||
|
||||||
|
breakdownNumber(listing.value, 'seller_ratio')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const externalAdjustmentPreview = computed(() => {
|
||||||
|
const coinM = Math.max(Number(externalAdjustForm.haf_coin_m || 0), 0)
|
||||||
|
const coinWan = coinM * 100
|
||||||
|
const referenceRatio = externalReferenceRatio.value
|
||||||
|
const referenceCoinPrice =
|
||||||
|
coinWan > 0 && referenceRatio > 0 ? roundMoney(coinWan / referenceRatio) : 0
|
||||||
|
let saleRatio = roundPricingRatio(Number(externalAdjustForm.sale_ratio || 0))
|
||||||
|
if (externalAdjustForm.adjust_mode === 'discount_amount') {
|
||||||
|
const desiredPrice = referenceCoinPrice - Number(externalAdjustForm.discount_amount_yuan || 0)
|
||||||
|
saleRatio = desiredPrice > 0 ? roundPricingRatio(coinWan / desiredPrice) : 0
|
||||||
|
}
|
||||||
|
const sellerCoinPrice = saleRatio > 0 ? roundMoney(coinWan / saleRatio) : 0
|
||||||
|
const actualDiscountAmount = Math.max(0, roundMoney(referenceCoinPrice - sellerCoinPrice))
|
||||||
|
const consumablePrice = roundMoney(
|
||||||
|
normalizedQuantity(externalAdjustForm.awm_bullets) * 0.6 +
|
||||||
|
normalizedQuantity(externalAdjustForm.level6_helmets) * 1.5 +
|
||||||
|
normalizedQuantity(externalAdjustForm.level6_armor) * 2.5
|
||||||
|
)
|
||||||
|
const sellerTotalPrice = roundMoney(sellerCoinPrice + consumablePrice)
|
||||||
|
const platformPricing = calculatePlatformPricing({
|
||||||
|
coinMAmount: coinM,
|
||||||
|
coinWanAmount: coinWan,
|
||||||
|
sellerRatio: saleRatio,
|
||||||
|
sellerCoinBasePrice: sellerCoinPrice,
|
||||||
|
sellerTotalPrice,
|
||||||
|
consumablePrice,
|
||||||
|
salePriceConfig: salePriceConfig.value,
|
||||||
|
})
|
||||||
|
const referencePlatformPricing = calculatePlatformPricing({
|
||||||
|
coinMAmount: coinM,
|
||||||
|
coinWanAmount: coinWan,
|
||||||
|
sellerRatio: referenceRatio,
|
||||||
|
sellerCoinBasePrice: referenceCoinPrice,
|
||||||
|
sellerTotalPrice: roundMoney(referenceCoinPrice + consumablePrice),
|
||||||
|
consumablePrice,
|
||||||
|
salePriceConfig: salePriceConfig.value,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
referenceRatio,
|
||||||
|
maxSaleRatio: roundPricingRatio(referenceRatio + 10),
|
||||||
|
referenceCoinPrice,
|
||||||
|
saleRatio,
|
||||||
|
sellerCoinPrice,
|
||||||
|
sellerTotalPrice,
|
||||||
|
consumablePrice,
|
||||||
|
actualDiscountAmount,
|
||||||
|
buyerRatio: platformPricing.buyerRatio,
|
||||||
|
buyerTotalPrice: platformPricing.buyerTotalPrice,
|
||||||
|
buyerDiscountAmount: Math.max(
|
||||||
|
0,
|
||||||
|
roundMoney(referencePlatformPricing.buyerTotalPrice - platformPricing.buyerTotalPrice)
|
||||||
|
),
|
||||||
|
isSale: referenceRatio > 0 && saleRatio > referenceRatio,
|
||||||
|
}
|
||||||
|
})
|
||||||
const statusTone = computed(() => {
|
const statusTone = computed(() => {
|
||||||
if (!listing.value) return 'info'
|
if (!listing.value) return 'info'
|
||||||
if (listing.value.status === 'published') return 'success'
|
if (listing.value.status === 'published') return 'success'
|
||||||
@@ -159,6 +271,96 @@ async function loadListing() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openExternalAdjustment() {
|
||||||
|
if (!listing.value || !canAdjustExternal.value || externalAdjustmentLocked.value) return
|
||||||
|
const row = listing.value
|
||||||
|
const referenceRatio = externalReferenceRatio.value
|
||||||
|
const currentSaleRatio = sellerSaleRatio(row) || referenceRatio
|
||||||
|
const referenceCoinPrice = referenceRatio > 0 ? roundMoney(getCoinWan(row) / referenceRatio) : 0
|
||||||
|
const currentCoinPrice = sellerCoinBasePrice(row)
|
||||||
|
externalAdjustForm.haf_coin_m = Number((row.haf_coin_amount / 1000000).toFixed(1))
|
||||||
|
externalAdjustForm.awm_bullets = getResourceQuantity(row, 'awmAmmo')
|
||||||
|
externalAdjustForm.level6_helmets = getResourceQuantity(row, 'helmet6')
|
||||||
|
externalAdjustForm.level6_armor = getResourceQuantity(row, 'armor6')
|
||||||
|
externalAdjustForm.deposit_yuan = centToYuan(row.deposit_amount_cent)
|
||||||
|
externalAdjustForm.adjust_mode = 'discount_amount'
|
||||||
|
externalAdjustForm.discount_amount_yuan = Math.max(
|
||||||
|
0,
|
||||||
|
roundMoney(referenceCoinPrice - currentCoinPrice)
|
||||||
|
)
|
||||||
|
externalAdjustForm.sale_ratio = currentSaleRatio
|
||||||
|
externalAdjustForm.reason = ''
|
||||||
|
externalAdjustVisible.value = true
|
||||||
|
try {
|
||||||
|
salePriceConfig.value = await fetchListingSalePriceConfig()
|
||||||
|
} catch {
|
||||||
|
salePriceConfig.value = emptyListingSalePriceConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitExternalAdjustment() {
|
||||||
|
if (!listing.value) return
|
||||||
|
const preview = externalAdjustmentPreview.value
|
||||||
|
if (externalAdjustForm.haf_coin_m <= 0) {
|
||||||
|
ElMessage.warning('请输入有效的纯币数量')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
externalAdjustForm.awm_bullets,
|
||||||
|
externalAdjustForm.level6_helmets,
|
||||||
|
externalAdjustForm.level6_armor,
|
||||||
|
].some(value => !Number.isInteger(Number(value)) || Number(value) < 0)
|
||||||
|
) {
|
||||||
|
ElMessage.warning('额外物品数量必须是非负整数')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
preview.saleRatio < preview.referenceRatio ||
|
||||||
|
preview.saleRatio > preview.maxSaleRatio ||
|
||||||
|
preview.sellerCoinPrice <= 0
|
||||||
|
) {
|
||||||
|
ElMessage.warning(
|
||||||
|
`发布比例应在 1:${ratioNumber(preview.referenceRatio)} 至 1:${ratioNumber(preview.maxSaleRatio)} 之间`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (preview.consumablePrice > 0 && externalAdjustForm.deposit_yuan <= preview.consumablePrice) {
|
||||||
|
ElMessage.warning('押金必须大于额外物品总价值')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!externalAdjustForm.reason.trim()) {
|
||||||
|
ElMessage.warning('请填写调整原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
externalAdjustSaving.value = true
|
||||||
|
try {
|
||||||
|
listing.value = await adjustExternalListing(listing.value.id, {
|
||||||
|
haf_coin_amount: Math.round(externalAdjustForm.haf_coin_m * 1000000),
|
||||||
|
awm_bullets: normalizedQuantity(externalAdjustForm.awm_bullets),
|
||||||
|
level6_helmets: normalizedQuantity(externalAdjustForm.level6_helmets),
|
||||||
|
level6_armor: normalizedQuantity(externalAdjustForm.level6_armor),
|
||||||
|
deposit_amount_cent: yuanToCent(externalAdjustForm.deposit_yuan),
|
||||||
|
adjust_mode: externalAdjustForm.adjust_mode,
|
||||||
|
discount_amount_cent:
|
||||||
|
externalAdjustForm.adjust_mode === 'discount_amount'
|
||||||
|
? yuanToCent(externalAdjustForm.discount_amount_yuan)
|
||||||
|
: undefined,
|
||||||
|
sale_ratio:
|
||||||
|
externalAdjustForm.adjust_mode === 'sale_ratio'
|
||||||
|
? Number(externalAdjustForm.sale_ratio)
|
||||||
|
: undefined,
|
||||||
|
reason: externalAdjustForm.reason.trim(),
|
||||||
|
})
|
||||||
|
externalAdjustVisible.value = false
|
||||||
|
ElMessage.success('外部商品已调整')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '调整失败'))
|
||||||
|
} finally {
|
||||||
|
externalAdjustSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copyListingCode() {
|
async function copyListingCode() {
|
||||||
if (!listing.value) return
|
if (!listing.value) return
|
||||||
try {
|
try {
|
||||||
@@ -302,6 +504,34 @@ function platformMarkupAmount(row: Listing) {
|
|||||||
return Math.max(0, roundMoney(buyerTotalPrice(row) - sellerTotalPrice(row)))
|
return Math.max(0, roundMoney(buyerTotalPrice(row) - sellerTotalPrice(row)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sellerSaleRatio(row: Listing) {
|
||||||
|
const value = breakdownNumber(row, 'seller_ratio')
|
||||||
|
if (value > 0) return value
|
||||||
|
const price = sellerCoinBasePrice(row)
|
||||||
|
return price > 0 ? roundPricingRatio(getCoinWan(row) / price) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function buyerSaleRatio(row: Listing) {
|
||||||
|
const value = breakdownNumber(row, 'buyer_ratio')
|
||||||
|
if (value > 0) return value
|
||||||
|
const price = buyerTotalPrice(row) - getListingConsumablePrice(row)
|
||||||
|
return price > 0 ? roundPricingRatio(getCoinWan(row) / price) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioNumber(value: number) {
|
||||||
|
const rounded = roundPricingRatio(value)
|
||||||
|
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioText(value: number) {
|
||||||
|
return value > 0 ? `1:${ratioNumber(value)}` : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedQuantity(value: number) {
|
||||||
|
const quantity = Number(value || 0)
|
||||||
|
return Number.isFinite(quantity) ? Math.max(0, Math.trunc(quantity)) : 0
|
||||||
|
}
|
||||||
|
|
||||||
function assetText(row: Listing, key: string) {
|
function assetText(row: Listing, key: string) {
|
||||||
const value = readAssetString(row, key)
|
const value = readAssetString(row, key)
|
||||||
if (value) return value
|
if (value) return value
|
||||||
@@ -359,6 +589,9 @@ async function openScreenshot(url: string) {
|
|||||||
<span>{{ listing.rank_level || '-' }}</span>
|
<span>{{ listing.rank_level || '-' }}</span>
|
||||||
<span>{{ listing.login_platform || '-' }}</span>
|
<span>{{ listing.login_platform || '-' }}</span>
|
||||||
<span>账号 {{ listing.account_id }}</span>
|
<span>账号 {{ listing.account_id }}</span>
|
||||||
|
<span v-if="listing.is_external_upload"
|
||||||
|
>外部 API · {{ listing.source_channel || '未填写' }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
@@ -366,6 +599,15 @@ async function openScreenshot(url: string) {
|
|||||||
<RouterLink :to="adminPath('listings')">
|
<RouterLink :to="adminPath('listings')">
|
||||||
<el-button>返回列表</el-button>
|
<el-button>返回列表</el-button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<el-button
|
||||||
|
v-if="canAdjustExternal"
|
||||||
|
type="primary"
|
||||||
|
:icon="EditPen"
|
||||||
|
:disabled="externalAdjustmentLocked"
|
||||||
|
@click="openExternalAdjustment"
|
||||||
|
>
|
||||||
|
调整商品
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
class="transfer-owner-trigger"
|
class="transfer-owner-trigger"
|
||||||
type="success"
|
type="success"
|
||||||
@@ -382,6 +624,152 @@ async function openScreenshot(url: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="externalAdjustVisible"
|
||||||
|
title="调整外部商品"
|
||||||
|
width="760px"
|
||||||
|
class="external-adjust-dialog"
|
||||||
|
>
|
||||||
|
<el-form label-width="112px" class="external-adjust-form">
|
||||||
|
<div class="external-adjust-grid">
|
||||||
|
<el-form-item label="纯币数量(M)" required>
|
||||||
|
<el-input-number
|
||||||
|
v-model="externalAdjustForm.haf_coin_m"
|
||||||
|
:min="0.1"
|
||||||
|
:step="1"
|
||||||
|
:precision="1"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="押金(元)" required>
|
||||||
|
<el-input-number
|
||||||
|
v-model="externalAdjustForm.deposit_yuan"
|
||||||
|
:min="0"
|
||||||
|
:step="10"
|
||||||
|
:precision="1"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="AWM子弹">
|
||||||
|
<el-input-number
|
||||||
|
v-model="externalAdjustForm.awm_bullets"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
:precision="0"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="六级头">
|
||||||
|
<el-input-number
|
||||||
|
v-model="externalAdjustForm.level6_helmets"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
:precision="0"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="六级甲">
|
||||||
|
<el-input-number
|
||||||
|
v-model="externalAdjustForm.level6_armor"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
:precision="0"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form-item label="调整方式" required>
|
||||||
|
<el-segmented v-model="externalAdjustForm.adjust_mode" :options="externalAdjustModes" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item
|
||||||
|
v-if="externalAdjustForm.adjust_mode === 'discount_amount'"
|
||||||
|
label="参考价降价(元)"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<el-input-number
|
||||||
|
v-model="externalAdjustForm.discount_amount_yuan"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
:precision="1"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-else label="目标发布比例" required>
|
||||||
|
<el-input-number
|
||||||
|
v-model="externalAdjustForm.sale_ratio"
|
||||||
|
:min="externalAdjustmentPreview.referenceRatio"
|
||||||
|
:max="externalAdjustmentPreview.maxSaleRatio"
|
||||||
|
:step="0.1"
|
||||||
|
:precision="1"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="external-adjust-preview">
|
||||||
|
<div>
|
||||||
|
<span>API参考比例</span>
|
||||||
|
<strong>{{ ratioText(externalAdjustmentPreview.referenceRatio) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>调整后比例</span>
|
||||||
|
<strong>{{ ratioText(externalAdjustmentPreview.saleRatio) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>参考纯币价</span>
|
||||||
|
<strong>{{ moneyYuan(externalAdjustmentPreview.referenceCoinPrice) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>调整后纯币价</span>
|
||||||
|
<strong>{{ moneyYuan(externalAdjustmentPreview.sellerCoinPrice) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>号主实际让利</span>
|
||||||
|
<strong>{{ moneyYuan(externalAdjustmentPreview.actualDiscountAmount) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>额外物品</span>
|
||||||
|
<strong>{{ moneyYuan(externalAdjustmentPreview.consumablePrice) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>号主合计</span>
|
||||||
|
<strong>{{ moneyYuan(externalAdjustmentPreview.sellerTotalPrice) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>租客售价</span>
|
||||||
|
<strong>{{ moneyYuan(externalAdjustmentPreview.buyerTotalPrice) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>租客实际降价</span>
|
||||||
|
<strong>{{ moneyYuan(externalAdjustmentPreview.buyerDiscountAmount) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>展示状态</span>
|
||||||
|
<el-tag :type="externalAdjustmentPreview.isSale ? 'danger' : 'info'" effect="light">
|
||||||
|
{{ externalAdjustmentPreview.isSale ? '特惠' : '常规' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form-item label="调整原因" required>
|
||||||
|
<el-input
|
||||||
|
v-model="externalAdjustForm.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="255"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="填写用户申请内容或资产修正原因"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="externalAdjustVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="externalAdjustSaving" @click="submitExternalAdjustment">
|
||||||
|
保存调整
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<div v-if="listing" class="listing-summary-bar">
|
<div v-if="listing" class="listing-summary-bar">
|
||||||
<div>
|
<div>
|
||||||
<span>商品状态</span>
|
<span>商品状态</span>
|
||||||
@@ -885,6 +1273,62 @@ async function openScreenshot(url: string) {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.external-adjust-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.external-adjust-dialog) {
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.external-adjust-dialog .el-dialog__body) {
|
||||||
|
max-height: calc(100vh - 180px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-adjust-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
column-gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-adjust-grid :deep(.el-input-number),
|
||||||
|
.external-adjust-form :deep(.el-input-number) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-adjust-preview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 4px 0 18px 112px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-adjust-preview > div {
|
||||||
|
display: grid;
|
||||||
|
min-height: 70px;
|
||||||
|
align-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-adjust-preview span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-adjust-preview strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.transfer-dialog {
|
.transfer-dialog {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -1046,6 +1490,15 @@ async function openScreenshot(url: string) {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.external-adjust-grid,
|
||||||
|
.external-adjust-preview {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.external-adjust-preview {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.transfer-arrow {
|
.transfer-arrow {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface Listing {
|
|||||||
owner_phone?: string
|
owner_phone?: string
|
||||||
owner_nickname?: string
|
owner_nickname?: string
|
||||||
source_channel?: string
|
source_channel?: string
|
||||||
|
is_external_upload?: boolean
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
game_name: string
|
game_name: string
|
||||||
@@ -255,6 +256,31 @@ export interface AdminListingPriceAdjustPayload {
|
|||||||
reason?: string
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminExternalListingAdjustMode = 'discount_amount' | 'sale_ratio'
|
||||||
|
|
||||||
|
export interface AdminExternalListingAdjustPayload {
|
||||||
|
haf_coin_amount: number
|
||||||
|
awm_bullets: number
|
||||||
|
level6_helmets: number
|
||||||
|
level6_armor: number
|
||||||
|
deposit_amount_cent: number
|
||||||
|
adjust_mode: AdminExternalListingAdjustMode
|
||||||
|
discount_amount_cent?: number
|
||||||
|
sale_ratio?: number
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adjustExternalListing(
|
||||||
|
id: number,
|
||||||
|
payload: AdminExternalListingAdjustPayload
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.put<ApiResponse<Listing>>(
|
||||||
|
`/admin/listings/${id}/external-adjustment`,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function adjustListingReviewPrice(
|
export async function adjustListingReviewPrice(
|
||||||
id: number,
|
id: number,
|
||||||
payload: AdminListingPriceAdjustPayload
|
payload: AdminListingPriceAdjustPayload
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const AUDIT_ACTION_OPTIONS: AuditOption[] = [
|
|||||||
{ value: 'listing.mark_abnormal', label: '商品标记异常', group: '商品', highRisk: true },
|
{ value: 'listing.mark_abnormal', label: '商品标记异常', group: '商品', highRisk: true },
|
||||||
{ value: 'listing.transfer_owner', label: '转移商品所有权', group: '商品', highRisk: true },
|
{ value: 'listing.transfer_owner', label: '转移商品所有权', group: '商品', highRisk: true },
|
||||||
{ value: 'listing.adjust_review_price', label: '审核调价', group: '商品' },
|
{ value: 'listing.adjust_review_price', label: '审核调价', group: '商品' },
|
||||||
|
{ value: 'listing.adjust_external', label: '调整外部商品', group: '商品', highRisk: true },
|
||||||
// 订单
|
// 订单
|
||||||
{ value: 'order.admin_close', label: '客服关闭订单', group: '订单', highRisk: true },
|
{ value: 'order.admin_close', label: '客服关闭订单', group: '订单', highRisk: true },
|
||||||
{ value: 'order.admin_seal', label: '客服封存订单', group: '订单', highRisk: true },
|
{ value: 'order.admin_seal', label: '客服封存订单', group: '订单', highRisk: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user