实现外部商品调价

This commit is contained in:
yml2213
2026-07-26 19:17:09 +08:00
parent 6b44ab599d
commit 99e8eb28af
14 changed files with 1163 additions and 17 deletions
+18
View File
@@ -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"`
@@ -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 {
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 == "" {
@@ -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 {
@@ -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):
@@ -19,6 +19,7 @@ type listingRow struct {
OwnerPhone string
OwnerNickname string
SourceChannel string `gorm:"column:source_channel"`
IsExternalUpload bool `gorm:"column:is_external_upload"`
Description string
GameName string
ServerRegion string
@@ -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,
@@ -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 ?
@@ -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 {
+1
View File
@@ -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)
@@ -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">
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 { computed, onMounted, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminUsers, type AdminUserItem } from '@/features/admin/api/adminUsers'
import { fetchAdminFileBlob } from '@/shared/api/files'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { adminPath } from '@/shared/utils/adminPath'
import { formatCentWithSymbol, formatMoneyWithSymbol, roundMoney } from '@/shared/utils/money'
import {
centToYuan,
formatCentWithSymbol,
formatMoneyWithSymbol,
roundMoney,
yuanToCent,
} from '@/shared/utils/money'
import {
adjustExternalListing,
adminMarkListingAbnormal,
adminOfflineListing,
adminTransferListingOwner,
emptyListingSalePriceConfig,
fetchAdminListing,
fetchListingSalePriceConfig,
type AdminExternalListingAdjustMode,
type PublishSalePriceConfig,
type Listing,
} from '@/features/listings'
import { useAdminSessionStore } from '@/stores/adminSession'
import {
listingReviewStatusLabel,
listingStatusLabel,
@@ -42,6 +54,9 @@ import {
readAssetNumber,
readAssetString,
} from '@/shared/utils/listingDisplay'
import { calculatePlatformPricing, roundRatio as roundPricingRatio } from '@/shared/utils/pricing'
const adminSession = useAdminSessionStore()
const route = useRoute()
const loading = ref(false)
@@ -55,6 +70,24 @@ const transferReason = ref('')
const userKeyword = ref('')
const userOptions = ref<AdminUserItem[]>([])
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(() =>
actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'
@@ -66,6 +99,15 @@ const canOperate = computed(
!['offline', 'abnormal'].includes(listing.value.status)
)
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(
() => selectedUser.value?.status === 'active' && selectedUser.value.realname_status === 'verified'
)
@@ -109,6 +151,14 @@ const ownerRows = computed(() => {
{ label: '号主损耗', value: moneyYuan(ownerLoss) },
{ label: '号主租金合计', value: moneyYuan(ownerTotal) },
{ 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: formatHafCoinM(getCoinWan(row)) },
{ label: '每日损耗', value: getDailyLoss(row) || '-' },
@@ -133,6 +183,68 @@ const hiddenSkinCount = computed(() => {
if (!listing.value) return 0
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(() => {
if (!listing.value) return 'info'
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() {
if (!listing.value) return
try {
@@ -302,6 +504,34 @@ function platformMarkupAmount(row: Listing) {
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) {
const value = readAssetString(row, key)
if (value) return value
@@ -359,6 +589,9 @@ async function openScreenshot(url: string) {
<span>{{ listing.rank_level || '-' }}</span>
<span>{{ listing.login_platform || '-' }}</span>
<span>账号 {{ listing.account_id }}</span>
<span v-if="listing.is_external_upload"
>外部 API · {{ listing.source_channel || '未填写' }}</span
>
</div>
</div>
<div class="toolbar-actions">
@@ -366,6 +599,15 @@ async function openScreenshot(url: string) {
<RouterLink :to="adminPath('listings')">
<el-button>返回列表</el-button>
</RouterLink>
<el-button
v-if="canAdjustExternal"
type="primary"
:icon="EditPen"
:disabled="externalAdjustmentLocked"
@click="openExternalAdjustment"
>
调整商品
</el-button>
<el-button
class="transfer-owner-trigger"
type="success"
@@ -382,6 +624,152 @@ async function openScreenshot(url: string) {
</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>
<span>商品状态</span>
@@ -885,6 +1273,62 @@ async function openScreenshot(url: string) {
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 {
display: grid;
gap: 18px;
@@ -1046,6 +1490,15 @@ async function openScreenshot(url: string) {
grid-template-columns: 1fr;
}
.external-adjust-grid,
.external-adjust-preview {
grid-template-columns: 1fr;
}
.external-adjust-preview {
margin-left: 0;
}
.transfer-arrow {
transform: rotate(90deg);
}
@@ -10,6 +10,7 @@ export interface Listing {
owner_phone?: string
owner_nickname?: string
source_channel?: string
is_external_upload?: boolean
title: string
description: string
game_name: string
@@ -255,6 +256,31 @@ export interface AdminListingPriceAdjustPayload {
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(
id: number,
payload: AdminListingPriceAdjustPayload
+1
View File
@@ -33,6 +33,7 @@ export const AUDIT_ACTION_OPTIONS: AuditOption[] = [
{ value: 'listing.mark_abnormal', label: '商品标记异常', group: '商品', highRisk: true },
{ value: 'listing.transfer_owner', label: '转移商品所有权', group: '商品', highRisk: true },
{ 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_seal', label: '客服封存订单', group: '订单', highRisk: true },