Files
hfb_sys/backend/internal/modules/listing/service_test.go
T
yml2213 69774458b0 fix(file): 修复商品详情截图与提现打款凭证等私有图片 403 权限问题
- 放宽公开商品截图 key 提取规则,支持任意合规 object key 转换为免鉴权公开端点
- 补充后台截图上传 listing_screenshot 与 operating-expense 场景识别
- dependencies 授权器支持已审核上架商品截图兜底放行、提现人打款凭证鉴权及变体后缀匹配
- /api/files 路由组支持用户与管理员双重鉴权 (AuthUserOrAdmin)
- 前端收款账号凭证与提现详情凭证统一升级为 AuthImage 组件并支持大图预览
2026-09-16 20:33:09 +08:00

1185 lines
38 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package listing
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
)
type stubConfigReader map[string]string
func (s stubConfigReader) FindValue(_ context.Context, key string) (string, error) {
return s[key], nil
}
func TestConsumableValueOnlyCountsChargedResources(t *testing.T) {
value := consumableValue(map[string]any{
"resources": []any{
map[string]any{"mode": "收费", "quantity": float64(5), "price": "0.1元/个"},
map[string]any{"mode": "赠送", "quantity": float64(3), "price": "0.6元/个"},
map[string]any{"mode": "收费", "quantity": float64(2), "price": "1元/2个"},
},
})
if value != 1.5 {
t.Fatalf("expected 1.5, got %.2f", value)
}
}
func TestCalculateAdminAdjustedPriceByRatio(t *testing.T) {
base, total, ratio := calculateAdminAdjustedPrice(AdminPriceAdjustRequest{BuyerRatio: 25}, 1000, 20)
if base != 40 || total != 60 || ratio != 25 {
t.Fatalf("unexpected adjusted price: base=%.2f total=%.2f ratio=%.2f", base, total, ratio)
}
}
func TestCalculateAdminAdjustedPriceByTotalPrice(t *testing.T) {
base, total, ratio := calculateAdminAdjustedPrice(AdminPriceAdjustRequest{BuyerTotalPriceCent: 7000}, 1000, 20)
if base != 50 || total != 70 || ratio != 20 {
t.Fatalf("unexpected adjusted price: base=%.2f total=%.2f ratio=%.2f", base, total, ratio)
}
}
func TestValidateRequestRequiresDepositAboveConsumables(t *testing.T) {
req := CreateRequest{
Title: "测试账号",
ServerRegion: "烽火地带",
PriceCent: 10000,
DepositAmountCent: 200,
HafCoinAmount: 1000000,
ScreenshotURLS: []string{"https://example.com/a.png"},
AssetSummary: map[string]any{
"resources": []any{
map[string]any{"mode": "收费", "quantity": float64(2), "price": "1元/个"},
},
},
}
if err := validateRequest(req, publishRules{}); err != ErrDepositTooLow {
t.Fatalf("expected ErrDepositTooLow, got %v", err)
}
req.DepositAmountCent = 300
if err := validateRequest(req, publishRules{}); err != nil {
t.Fatalf("expected valid request, got %v", err)
}
}
func TestValidateRequiredOnlineTime(t *testing.T) {
req := CreateRequest{
AssetSummary: map[string]any{
"online_time": map[string]any{
"start": "09:00",
"end": "23:00",
},
},
}
if err := validateRequiredOnlineTime(req); err != nil {
t.Fatalf("expected valid online time, got %v", err)
}
}
func TestValidateRequiredOnlineTimeRejectsMissing(t *testing.T) {
req := CreateRequest{AssetSummary: map[string]any{}}
if err := validateRequiredOnlineTime(req); err != ErrMissingOnlineTime {
t.Fatalf("expected ErrMissingOnlineTime, got %v", err)
}
}
func TestValidateRequiredOnlineTimeAllowsCrossDay(t *testing.T) {
req := CreateRequest{
AssetSummary: map[string]any{
"online_time": map[string]any{
"start": "23:00",
"end": "09:00",
},
},
}
if err := validateRequiredOnlineTime(req); err != nil {
t.Fatalf("expected cross-day range to be valid, got %v", err)
}
}
func TestValidateRequiredOnlineTimeRejectsSameStartEnd(t *testing.T) {
req := CreateRequest{
AssetSummary: map[string]any{
"online_time": map[string]any{
"start": "10:00",
"end": "10:00",
},
},
}
if err := validateRequiredOnlineTime(req); err != ErrInvalidOnlineTime {
t.Fatalf("expected ErrInvalidOnlineTime, got %v", err)
}
}
func TestCreateRequiresPublishAgreements(t *testing.T) {
service := NewService(&Repository{}, nil)
_, err := service.Create(t.Context(), 1, CreateRequest{})
if err != ErrAgreementRequired {
t.Fatalf("expected ErrAgreementRequired, got %v", err)
}
}
func TestPublishCooldownUsesConfiguredMinutes(t *testing.T) {
service := NewService(nil, stubConfigReader{publishCooldownMinutesKey: "2"})
cooldown, err := service.publishCooldown(t.Context())
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if cooldown != 2*time.Minute {
t.Fatalf("expected 2m cooldown, got %s", cooldown)
}
}
func TestPublishCooldownCanBeDisabled(t *testing.T) {
service := NewService(nil, stubConfigReader{publishCooldownMinutesKey: "0"})
cooldown, err := service.publishCooldown(t.Context())
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if cooldown != 0 {
t.Fatalf("expected disabled cooldown, got %s", cooldown)
}
}
func TestRepositoryCreateRejectsRecentOwnerPublish(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("failed to migrate test db: %v", err)
}
user := model.User{Phone: "18800000000", RealnameStatus: "verified", Status: "active"}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
latestCreatedAt := time.Now().Add(-2 * time.Minute)
if err := db.Create(&model.RentalListing{
ListingNo: "202606270001",
AccountID: 1,
OwnerID: user.ID,
Status: "published",
CreatedAt: latestCreatedAt,
ReviewStatus: "approved",
}).Error; err != nil {
t.Fatalf("failed to create latest listing: %v", err)
}
repo := NewRepository(db, nil)
_, err := repo.Create(t.Context(), user.ID, validCreateRequest(), false, 5*time.Minute)
var cooldownErr PublishCooldownError
if !errors.As(err, &cooldownErr) {
t.Fatalf("expected PublishCooldownError, got %v", err)
}
if cooldownErr.Remaining <= 0 {
t.Fatalf("expected positive remaining cooldown, got %s", cooldownErr.Remaining)
}
}
func TestRepositoryUpdateAllowsOfflineListingResubmit(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("failed to migrate test db: %v", err)
}
repo := NewRepository(db, nil)
req := CreateRequest{
Title: "测试账号",
ServerRegion: "QQ",
LoginPlatform: "QQ账号密码",
RankLevel: "黑鹰",
HafCoinAmount: 100000000,
PriceCent: 10000,
DepositAmountCent: 50000,
ScreenshotURLS: []string{"https://example.com/a.png"},
AssetSummary: map[string]any{
"online_time": map[string]any{"start": "09:00", "end": "23:00"},
},
}
created, err := repo.Create(t.Context(), 1, req, false, 0)
if err != nil {
t.Fatalf("failed to create listing: %v", err)
}
if _, err := repo.Offline(t.Context(), 1, created.ID); err != nil {
t.Fatalf("failed to offline listing: %v", err)
}
req.Title = "二次编辑账号"
updated, err := repo.Update(t.Context(), 1, created.ID, req, true)
if err != nil {
t.Fatalf("expected offline listing can be updated, got %v", err)
}
if updated.Title != "二次编辑账号" {
t.Fatalf("expected title updated, got %q", updated.Title)
}
if updated.Status != "draft" || updated.ReviewStatus != "pending" {
t.Fatalf("expected draft/pending after resubmit, got %s/%s", updated.Status, updated.ReviewStatus)
}
}
func TestRepositoryRejectsCompletedListingOwnerMutations(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("failed to migrate test db: %v", err)
}
repo := NewRepository(db, nil)
account := model.GameAccount{
OwnerID: 1,
GameName: "delta_force",
ServerRegion: "QQ",
LoginPlatform: "QQ账号密码",
Title: "已完成账号",
Status: "offline",
}
if err := db.Create(&account).Error; err != nil {
t.Fatalf("failed to create account: %v", err)
}
listing := model.RentalListing{
ListingNo: "202606270099",
AccountID: account.ID,
OwnerID: 1,
Status: "completed",
ReviewStatus: "approved",
PriceCent: 10000,
DepositAmountCent: 50000,
}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("failed to create listing: %v", err)
}
if _, err := repo.Update(t.Context(), 1, listing.ID, validCreateRequest(), false); err != ErrListingLocked {
t.Fatalf("Update expected ErrListingLocked, got %v", err)
}
if _, err := repo.SubmitReview(t.Context(), 1, listing.ID, false); err != ErrListingLocked {
t.Fatalf("SubmitReview expected ErrListingLocked, got %v", err)
}
if _, err := repo.Offline(t.Context(), 1, listing.ID); err != ErrListingLocked {
t.Fatalf("Offline expected ErrListingLocked, got %v", err)
}
}
func validCreateRequest() CreateRequest {
return CreateRequest{
Title: "测试账号",
ServerRegion: "QQ",
LoginPlatform: "QQ账号密码",
RankLevel: "黑鹰",
HafCoinAmount: 100000000,
PriceCent: 10000,
DepositAmountCent: 50000,
ScreenshotURLS: []string{"https://example.com/a.png"},
AssetSummary: map[string]any{
"online_time": map[string]any{"start": "09:00", "end": "23:00"},
},
}
}
func TestApplySellerListingPriceUsesSellerTotalPrice(t *testing.T) {
item := &ListingDTO{
PriceCent: 23800,
AssetSummary: map[string]any{
"price_breakdown": map[string]any{
"seller_total_price": 200,
},
},
}
applySellerListingPrice(item)
if item.PriceCent != 20000 {
t.Fatalf("expected seller price 20000, got %d", item.PriceCent)
}
}
func TestApplySellerListingPriceKeepsFallbackPrice(t *testing.T) {
item := &ListingDTO{
PriceCent: 23800,
AssetSummary: map[string]any{
"price_breakdown": map[string]any{
"seller_total_price": 0,
},
},
}
applySellerListingPrice(item)
if item.PriceCent != 23800 {
t.Fatalf("expected fallback price 23800, got %d", item.PriceCent)
}
}
func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
item := &ListingDTO{
ID: 1,
Status: "published",
ReviewStatus: "approved",
SourceChannel: "淘宝",
ScreenshotURLS: []string{
"https://example.com/account.png",
},
AssetSummary: map[string]any{
"import_meta": map[string]any{"uploader_name": "客服1"},
"price_breakdown": map[string]any{"buyer_total_price": 100},
"screenshot_groups": map[string]any{"coin": []string{"https://example.com/account.png"}},
},
}
applyPublicListingURLs(item)
if item.SourceChannel != "" {
t.Fatalf("expected public listing source channel hidden, got %q", item.SourceChannel)
}
if _, ok := item.AssetSummary["import_meta"]; ok {
t.Fatal("expected import_meta hidden from public listing")
}
if _, ok := item.AssetSummary["price_breakdown"]; ok {
t.Fatal("expected price_breakdown hidden from public listing")
}
}
func uint64Pointer(value uint64) *uint64 {
return &value
}
func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
items, err := parseExternalUploadItems(json.RawMessage(validExternalUploadDataJSON()))
if err != nil {
t.Fatalf("expected single upload parsed, got %v", err)
}
if len(items) != 1 || items[0].LoginMethod != "QQ账号密码" {
t.Fatalf("unexpected items: %#v", items)
}
}
func TestParseExternalUploadItemsReportsMissingFields(t *testing.T) {
_, err := parseExternalUploadItems(json.RawMessage(`{"loginMethod":"QQ账号密码","currency":{"hafuCoin":197.1}}`))
if err == nil {
t.Fatal("expected validation error")
}
validationErr, ok := err.(UploadValidationError)
if !ok {
t.Fatalf("expected UploadValidationError, got %T", err)
}
expected := []string{"data.rank", "data.level", "data.currency.recycleRatio", "data.dailyConsumption"}
for _, field := range expected {
if !containsString(validationErr.Missing, field) {
t.Fatalf("expected missing %s in %#v", field, validationErr.Missing)
}
}
}
func TestImportExternalUploadStoresSourceChannel(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("failed to migrate test db: %v", err)
}
if err := database.AutoMigrateIfMissing(db, &model.AdminUser{}, &model.ListingUpload{}); err != nil {
t.Fatalf("failed to migrate upload tables: %v", err)
}
admin := model.AdminUser{
Username: "客服1",
PasswordHash: "hash",
Nickname: "客服1",
Status: "active",
}
if err := db.Create(&admin).Error; err != nil {
t.Fatalf("failed to create admin: %v", err)
}
service := NewService(NewRepository(db, nil), nil)
resp, err := service.ImportExternalUpload(t.Context(), ExternalUploadRequest{
UploaderName: "客服1",
SourceChannel: "淘宝",
Data: json.RawMessage(validExternalUploadDataJSON()),
}, ExternalUploadMeta{})
if err != nil {
t.Fatalf("ImportExternalUpload() error = %v", err)
}
if resp.Success != 1 || resp.ListingID == 0 {
t.Fatalf("unexpected response: %#v", resp)
}
var upload model.ListingUpload
if err := db.Where("listing_id = ?", resp.ListingID).First(&upload).Error; err != nil {
t.Fatalf("failed to find listing upload: %v", err)
}
if upload.SourceChannel != "淘宝" {
t.Fatalf("source channel = %q, want 淘宝", upload.SourceChannel)
}
}
func TestListPendingReviewIncludesSourceChannel(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("failed to migrate test db: %v", err)
}
if err := database.AutoMigrateIfMissing(db, &model.ListingUpload{}); err != nil {
t.Fatalf("failed to migrate listing uploads: %v", err)
}
user := model.User{Phone: "18800000001", RealnameStatus: "verified", Status: "active"}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
account := model.GameAccount{
OwnerID: user.ID,
GameName: "delta_force",
ServerRegion: "QQ",
LoginPlatform: "QQ账号密码",
Title: "待审核账号",
RankLevel: "黑鹰",
Status: "draft",
}
if err := db.Create(&account).Error; err != nil {
t.Fatalf("failed to create account: %v", err)
}
listing := model.RentalListing{
ListingNo: "202607250001",
AccountID: account.ID,
OwnerID: user.ID,
PriceCent: 10000,
DepositAmountCent: 50000,
Status: "draft",
ReviewStatus: "pending",
}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("failed to create listing: %v", err)
}
listingID := listing.ID
if err := db.Create(&model.ListingUpload{
UploaderName: "客服1",
SourceChannel: "淘宝",
OwnerID: &user.ID,
ListingID: &listingID,
Status: "draft_created",
}).Error; err != nil {
t.Fatalf("failed to create listing upload: %v", err)
}
items, err := NewRepository(db, nil).ListPendingReview(t.Context())
if err != nil {
t.Fatalf("ListPendingReview() error = %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 pending listing, got %d", len(items))
}
if items[0].SourceChannel != "淘宝" {
t.Fatalf("source channel = %q, want 淘宝", items[0].SourceChannel)
}
}
func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{
LoginMethod: "QQ账号密码",
Rank: "黑鹰",
Level: 60,
SafeSlots: 9,
SecretKD: 1.6,
DailyLossM: 7,
Deposit: 400,
BanRecord: "有",
CommonRegion: "郑州",
OwnerOnlineTime: "8:00-1:00",
Remark: "有语音封禁\n封禁历史:2026/01/23封禁3天",
Currency: ExternalUploadCurrency{
HafuCoin: 197.1,
RecycleRatio: 43,
RecycleRent: 458,
},
DailyConsumption: ExternalDailyConsumption{Stamina: 7, Weight: 7},
Inventory: ExternalUploadInventory{
AWMBullets: 45,
Level6Helmets: 14,
Level6Armor: 17,
Skins: []string{"信条", "电锯惊魂"},
},
}, defaultSalePriceConfig())
if req.Description != "有语音封禁\n封禁历史:2026/01/23封禁3天" {
t.Fatalf("Description = %q", req.Description)
}
if req.AssetSummary["remark"] != "有语音封禁\n封禁历史:2026/01/23封禁3天" {
t.Fatalf("remark = %#v", req.AssetSummary["remark"])
}
if req.AssetSummary["ban_record"] != "有封禁记录" {
t.Fatalf("ban_record = %#v", req.AssetSummary["ban_record"])
}
if req.ServerRegion != "QQ" {
t.Fatalf("expected QQ server region, got %q", req.ServerRegion)
}
if req.HafCoinAmount != 197100000 {
t.Fatalf("expected haf coin amount 197100000, got %d", req.HafCoinAmount)
}
if req.PriceCent != 59590 || req.DepositAmountCent != 40000 {
t.Fatalf("unexpected price/deposit cent %d/%d", req.PriceCent, req.DepositAmountCent)
}
if req.AssetSummary["season_insurance"] != "3*3" {
t.Fatalf("expected 3*3 insurance, got %#v", req.AssetSummary["season_insurance"])
}
if req.AssetSummary["daily_loss_m"] != float64(7) {
t.Fatalf("expected daily loss 7, got %#v", req.AssetSummary["daily_loss_m"])
}
if req.AssetSummary["stamina_level"] != "7级" || req.AssetSummary["load_level"] != "7级" {
t.Fatalf("unexpected stamina/load: %#v", req.AssetSummary)
}
if req.AssetSummary["online_time_text"] != "08:00 至 01:00" {
t.Fatalf("online_time_text = %#v", req.AssetSummary["online_time_text"])
}
if req.AssetSummary["early_morning_response"] != "否" {
t.Fatalf("early_morning_response = %#v, want 否", req.AssetSummary["early_morning_response"])
}
onlineTime, ok := req.AssetSummary["online_time"].(map[string]any)
if !ok {
t.Fatalf("online_time missing: %#v", req.AssetSummary["online_time"])
}
if onlineTime["start"] != "08:00" || onlineTime["end"] != "01:00" {
t.Fatalf("online_time = %#v", onlineTime)
}
groups := req.AssetSummary["skin_groups"].(map[string][]string)
if len(groups["melee"]) != 2 {
t.Fatalf("expected melee skins mapped, got %#v", groups)
}
if _, ok := groups["imported"]; ok {
t.Fatalf("did not expect imported group, got %#v", groups)
}
if len(req.ScreenshotURLS) != 1 || req.ScreenshotURLS[0] != defaultUploadScreenshot {
t.Fatalf("expected default screenshot, got %#v", req.ScreenshotURLS)
}
breakdown, ok := req.AssetSummary["price_breakdown"].(map[string]any)
if !ok {
t.Fatalf("price_breakdown = %#v", req.AssetSummary["price_breakdown"])
}
if breakdown["seller_coin_base_price"] != 458.0 ||
breakdown["consumable_price"] != 90.5 ||
breakdown["seller_total_price"] != 548.5 ||
breakdown["buyer_coin_base_price"] != 505.4 ||
breakdown["buyer_total_price"] != 595.9 ||
breakdown["buyer_ratio"] != 39.0 ||
breakdown["platform_markup_amount"] != 47.4 ||
breakdown["platform_rule_type"] != "ratio_subtract" {
t.Fatalf("unexpected price breakdown: %#v", breakdown)
}
if req.AssetSummary["publish_ratio"] != 39.0 {
t.Fatalf("publish_ratio = %#v", req.AssetSummary["publish_ratio"])
}
}
func TestServerRegionFromLoginMethod(t *testing.T) {
tests := []struct {
name string
loginMethod string
want string
}{
{name: "QQ账号密码", loginMethod: "QQ账号密码", want: "QQ"},
{name: "微信扫码", loginMethod: "微信扫码", want: "微信"},
{name: "VX扫码", loginMethod: "VX扫码", want: "微信"},
{name: "小写WX扫码", loginMethod: "wx扫码", want: "微信"},
{name: "Steam", loginMethod: "Steam令牌", want: "Steam"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := serverRegionFromLoginMethod(tt.loginMethod); got != tt.want {
t.Fatalf("serverRegionFromLoginMethod(%q) = %q, want %q", tt.loginMethod, got, tt.want)
}
})
}
}
func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing.T) {
req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{
LoginMethod: "QQ账号密码",
Rank: "黑鹰",
Level: 60,
SafeSlots: 9,
Deposit: 400,
Currency: ExternalUploadCurrency{
HafuCoin: 282,
RecycleRatio: 42.8,
RecycleRent: 659,
},
DailyConsumption: ExternalDailyConsumption{Stamina: 7, Weight: 7},
Inventory: ExternalUploadInventory{
AWMBullets: 10,
Level6Armor: 2,
},
}, defaultSalePriceConfig())
breakdown, ok := req.AssetSummary["price_breakdown"].(map[string]any)
if !ok {
t.Fatalf("price_breakdown = %#v", req.AssetSummary["price_breakdown"])
}
if breakdown["seller_coin_base_price"] != 659.0 ||
breakdown["consumable_price"] != 11.0 ||
breakdown["seller_total_price"] != 670.0 {
t.Fatalf("回收租金未按纯币价与额外物品拆分:%#v", breakdown)
}
if breakdown["buyer_coin_base_price"] != 717.6 ||
breakdown["buyer_total_price"] != 728.6 ||
breakdown["buyer_ratio"] != 39.3 ||
breakdown["platform_markup_amount"] != 58.6 ||
breakdown["platform_rule_type"] != "ratio_subtract" {
t.Fatalf("外部上传未按比例加价:%#v", breakdown)
}
if req.PriceCent != 72860 {
t.Fatalf("PriceCent = %d, want 72860", req.PriceCent)
}
}
func TestExternalSkinGroupsClassifiesKnownSkins(t *testing.T) {
groups := externalSkinGroups([]string{"暗星", "凌霄成卫", "蛊-不羁人生", "未知皮肤X"})
if got := groups["melee"]; len(got) != 1 || got[0] != "暗星" {
t.Fatalf("melee = %#v", got)
}
if got := groups["operatorRed"]; len(got) != 1 || got[0] != "凌霄成卫" {
t.Fatalf("operatorRed = %#v", got)
}
if got := groups["operatorGold"]; len(got) != 1 || got[0] != "蛊-不羁人生" {
t.Fatalf("operatorGold = %#v", got)
}
if got := groups["other"]; len(got) != 1 || got[0] != "未知皮肤X" {
t.Fatalf("other = %#v", got)
}
if _, ok := groups["imported"]; ok {
t.Fatalf("unexpected imported group: %#v", groups)
}
}
func validExternalUploadDataJSON() string {
return `{
"loginMethod":"QQ账号密码",
"rank":"黑鹰",
"level":60,
"safeSlots":9,
"secretKD":1.6,
"dailyLossM":7,
"deposit":400,
"currency":{"hafuCoin":197.1,"recycleRatio":43,"recycleRent":458},
"dailyConsumption":{"stamina":7,"weight":7}
}`
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func TestListPublicWithDistributionSiteCode(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("migrate err: %v", err)
}
if err := database.AutoMigrateIfMissing(db, &model.DistributionSite{}, &model.DistributionSiteAccount{}); err != nil {
t.Fatalf("migrate distribution err: %v", err)
}
repo := NewRepository(db, nil)
account1 := model.GameAccount{Title: "号1", GameName: "delta", Status: "active", HafCoinAmount: 100_000_000}
account2 := model.GameAccount{Title: "号2", GameName: "delta", Status: "active"}
db.Create(&account1)
db.Create(&account2)
l1 := model.RentalListing{ListingNo: "L101", AccountID: account1.ID, Status: "published", ReviewStatus: "approved", PriceCent: 10000}
l2 := model.RentalListing{ListingNo: "L102", AccountID: account2.ID, Status: "published", ReviewStatus: "approved", PriceCent: 2000}
db.Create(&l1)
db.Create(&l2)
site := model.DistributionSite{SiteCode: "site_alpha", SiteName: "分销A", Domain: "a.com", Status: "active", DisplayAccountEnabled: true}
db.Create(&site)
dsa := model.DistributionSiteAccount{SiteID: site.ID, AccountID: account1.ID, ListingID: l1.ID, DisplayStatus: "enabled", ShelfRatioDelta: 20}
db.Create(&dsa)
res, err := repo.ListPublic(context.Background(), PublicListQuery{
DistributionSiteCode: "site_alpha",
})
if err != nil {
t.Fatalf("ListPublic err: %v", err)
}
if len(res.Items) != 1 || res.Items[0].ListingNo != "L101" {
t.Fatalf("expected only L101, got %+v", res.Items)
}
// 主站 100 元 (10000 分,100 比例);分站提价减 20 比例 (80 比例) → 售价 125 元 (12500 分)
if res.Items[0].PriceCent != 12500 {
t.Fatalf("expected public price 12500 with ratio delta, got %d", res.Items[0].PriceCent)
}
detail, err := repo.FindPublic(context.Background(), l1.ID, "site_alpha")
if err != nil {
t.Fatalf("FindPublic err: %v", err)
}
if detail.PriceCent != 12500 {
t.Fatalf("expected detail price 12500 with ratio delta, got %d", detail.PriceCent)
}
mainDetail, err := repo.FindPublic(context.Background(), l1.ID, "")
if err != nil {
t.Fatalf("FindPublic main err: %v", err)
}
if mainDetail.PriceCent != 10000 {
t.Fatalf("main site must keep original price, got %d", mainDetail.PriceCent)
}
allRes, err := repo.ListPublic(context.Background(), PublicListQuery{})
if err != nil {
t.Fatalf("ListPublic err: %v", err)
}
if len(allRes.Items) != 2 {
t.Fatalf("expected 2 listings for main site, got %d", len(allRes.Items))
}
}
func TestApplyPublicScreenshotGroups(t *testing.T) {
rawURLs := []string{
"/api/files/object?key=listing/coin.png",
"/api/files/object?key=listing/skin1.png",
}
publicURLs := []string{
"/api/listings/88/screenshots/0",
"/api/listings/88/screenshots/1",
}
summary := map[string]any{
"screenshot_groups": map[string]any{
"coin": []any{"/api/files/object?key=listing/coin.png"},
"skin": []string{"/api/files/object?key=listing/skin1.png"},
},
}
applyPublicScreenshotGroups(88, summary, rawURLs, publicURLs)
groups := summary["screenshot_groups"].(map[string]any)
coinList := groups["coin"].([]string)
if len(coinList) != 1 || coinList[0] != "/api/listings/88/screenshots/0" {
t.Fatalf("expected /api/listings/88/screenshots/0, got %#v", coinList)
}
skinList := groups["skin"].([]string)
if len(skinList) != 1 || skinList[0] != "/api/listings/88/screenshots/1" {
t.Fatalf("expected /api/listings/88/screenshots/1, got %#v", skinList)
}
}
func TestPublicScreenshotURLsWithMiscAndListingKeys(t *testing.T) {
rawURLs := []string{
"/api/files/object?key=misc%2F2026%2F09%2F16%2F984a80162e94e3b89090d1f8.webp",
"/api/files/object?key=listing%2Fnormal.webp",
"https://example.com/external.jpg",
}
publicURLs := publicScreenshotURLs(29969, rawURLs, "published", "approved")
if len(publicURLs) != 3 {
t.Fatalf("expected 3 public URLs, got %d", len(publicURLs))
}
if publicURLs[0] != "/api/listings/29969/screenshots/0" {
t.Fatalf("expected /api/listings/29969/screenshots/0, got %q", publicURLs[0])
}
if publicURLs[1] != "/api/listings/29969/screenshots/1" {
t.Fatalf("expected /api/listings/29969/screenshots/1, got %q", publicURLs[1])
}
if publicURLs[2] != "https://example.com/external.jpg" {
t.Fatalf("external URL should remain unchanged, got %q", publicURLs[2])
}
coverURL := publicCoverURL(29969, rawURLs, "published", "approved")
if coverURL != "/api/listings/29969/cover" {
t.Fatalf("expected /api/listings/29969/cover, got %q", coverURL)
}
summary := map[string]any{
"screenshot_groups": map[string]any{
"coin": []string{"/api/files/object?key=misc%2F2026%2F09%2F16%2F984a80162e94e3b89090d1f8.webp"},
},
}
applyPublicScreenshotGroups(29969, summary, rawURLs, publicURLs)
groups := summary["screenshot_groups"].(map[string]any)
coinList := groups["coin"].([]string)
if len(coinList) != 1 || coinList[0] != "/api/listings/29969/screenshots/0" {
t.Fatalf("expected mapped /api/listings/29969/screenshots/0, got %#v", coinList)
}
}
func TestUpdateScreenshots(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("failed to migrate test db: %v", err)
}
if err := database.AutoMigrateIfMissing(db, &model.AuditLog{}); err != nil {
t.Fatalf("failed to migrate audit log: %v", err)
}
repo := NewRepository(db, nil)
account := model.GameAccount{
OwnerID: 1,
GameName: "delta_force",
ServerRegion: "QQ",
LoginPlatform: "QQ账号密码",
Title: "待审核商品",
Status: "draft",
}
if err := db.Create(&account).Error; err != nil {
t.Fatalf("failed to create account: %v", err)
}
listing := model.RentalListing{
ListingNo: "202609070001",
AccountID: account.ID,
OwnerID: 1,
Status: "draft",
ReviewStatus: "pending",
}
if err := db.Create(&listing).Error; err != nil {
t.Fatalf("failed to create listing: %v", err)
}
meta := AuditMeta{IP: "127.0.0.1", UserAgent: "TestAgent", RequestID: "req-1"}
req := AdminUpdateScreenshotsRequest{
ScreenshotURLs: []string{
"/api/files/object?key=edited1.png",
"/api/files/object?key=edited2.png",
},
ScreenshotGroups: map[string]any{
"coin": []string{"/api/files/object?key=edited1.png"},
"gameId": []string{"/api/files/object?key=edited2.png"},
},
}
updated, err := repo.UpdateScreenshots(t.Context(), 999, listing.ID, req, meta)
if err != nil {
t.Fatalf("UpdateScreenshots failed: %v", err)
}
if len(updated.ScreenshotURLS) != 2 || updated.ScreenshotURLS[0] != "/api/files/object?key=edited1.png" {
t.Fatalf("unexpected screenshot_urls: %#v", updated.ScreenshotURLS)
}
groups, ok := updated.AssetSummary["screenshot_groups"].(map[string]any)
if !ok {
t.Fatalf("screenshot_groups not found in AssetSummary: %#v", updated.AssetSummary)
}
coinList, ok := groups["coin"].([]string)
if !ok {
if anyList, isAny := groups["coin"].([]any); isAny {
for _, v := range anyList {
coinList = append(coinList, v.(string))
}
}
}
if len(coinList) != 1 || coinList[0] != "/api/files/object?key=edited1.png" {
t.Fatalf("unexpected coin screenshots: %#v", coinList)
}
}
func TestCreateListingWithDistributionSiteCode(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("migrate err: %v", err)
}
repo := NewRepository(db, nil)
user := model.User{Phone: "18800000001", Nickname: "分销卖家", RealnameStatus: "verified", Status: "active"}
db.Create(&user)
site := model.DistributionSite{
SiteCode: "dist_test_1",
SiteName: "测试分销商1",
Domain: "d1.com",
Status: "active",
DisplayAccountEnabled: true,
}
db.Create(&site)
req := CreateRequest{
Title: "分销上架商品",
ServerRegion: "QQ",
LoginPlatform: "PC",
PriceCent: 8800,
}
dto, err := repo.Create(context.Background(), user.ID, req, true, 0, "dist_test_1")
if err != nil {
t.Fatalf("repo.Create failed: %v", err)
}
if dto.DistributionSiteID == nil || *dto.DistributionSiteID != site.ID {
t.Fatalf("expected DistributionSiteID=%d, got %v", site.ID, dto.DistributionSiteID)
}
if dto.DistributionSiteCode != "dist_test_1" {
t.Fatalf("expected dist_test_1, got %s", dto.DistributionSiteCode)
}
if dto.DistributionSiteName != "测试分销商1" {
t.Fatalf("expected 测试分销商1, got %s", dto.DistributionSiteName)
}
expectedSource := "分销站 - 测试分销商1 (dist_test_1)"
if dto.SourceChannel != expectedSource {
t.Fatalf("expected %s, got %s", expectedSource, dto.SourceChannel)
}
// 验证 distribution_site_accounts 自动创建并启用
var dsa model.DistributionSiteAccount
if err := db.Where("site_id = ? AND listing_id = ?", site.ID, dto.ID).First(&dsa).Error; err != nil {
t.Fatalf("failed to find distribution_site_account: %v", err)
}
if dsa.DisplayStatus != "enabled" {
t.Fatalf("expected enabled, got %s", dsa.DisplayStatus)
}
var stored model.RentalListing
if err := db.First(&stored, dto.ID).Error; err != nil {
t.Fatalf("load listing: %v", err)
}
if stored.SourceChannel != site.SiteCode {
t.Fatalf("stored source_channel = %q, want %q", stored.SourceChannel, site.SiteCode)
}
// 验证审核工作台列表展示分销站来源
pending, err := repo.ListPendingReview(context.Background())
if err != nil {
t.Fatalf("ListPendingReview failed: %v", err)
}
if len(pending) != 1 {
t.Fatalf("expected 1 pending item, got len=%d", len(pending))
}
if pending[0].SourceChannel != expectedSource {
t.Fatalf("expected pending SourceChannel=%s, got %s", expectedSource, pending[0].SourceChannel)
}
}
func TestCreateListingInvalidDistributionSiteCode(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("migrate err: %v", err)
}
repo := NewRepository(db, nil)
user := model.User{Phone: "18800000011", Nickname: "分销卖家", RealnameStatus: "verified", Status: "active"}
db.Create(&user)
inactive := model.DistributionSite{
SiteCode: "dist_inactive",
SiteName: "已停用分销站",
Domain: "inactive.example.com",
Status: "disabled",
DisplayAccountEnabled: true,
}
db.Create(&inactive)
req := CreateRequest{Title: "无效站点商品", ServerRegion: "QQ", LoginPlatform: "PC", PriceCent: 1000}
if _, err := repo.Create(context.Background(), user.ID, req, true, 0, "does_not_exist"); !errors.Is(err, ErrDistributionSiteInvalid) {
t.Fatalf("unknown site err = %v, want ErrDistributionSiteInvalid", err)
}
if _, err := repo.Create(context.Background(), user.ID, req, true, 0, "dist_inactive"); !errors.Is(err, ErrDistributionSiteInvalid) {
t.Fatalf("inactive site err = %v, want ErrDistributionSiteInvalid", err)
}
var count int64
if err := db.Model(&model.RentalListing{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("invalid site must not create listing, got %d", count)
}
}
func TestListMineSiteIsolation(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("migrate err: %v", err)
}
repo := NewRepository(db, nil)
user := model.User{Phone: "18800000002", Nickname: "多站卖家", RealnameStatus: "verified", Status: "active"}
db.Create(&user)
site := model.DistributionSite{
SiteCode: "dist_x",
SiteName: "分销X",
Domain: "x.com",
Status: "active",
DisplayAccountEnabled: true,
}
db.Create(&site)
req1 := CreateRequest{Title: "主站商品", ServerRegion: "QQ", LoginPlatform: "PC", PriceCent: 1000}
l1, err := repo.Create(context.Background(), user.ID, req1, false, 0)
if err != nil {
t.Fatalf("Create main failed: %v", err)
}
req2 := CreateRequest{Title: "分销商品", ServerRegion: "QQ", LoginPlatform: "PC", PriceCent: 2000}
l2, err := repo.Create(context.Background(), user.ID, req2, false, 0, "dist_x")
if err != nil {
t.Fatalf("Create dist failed: %v", err)
}
// 1. 分销站查询只返回分销站商品
distItems, err := repo.ListMine(context.Background(), user.ID, "dist_x")
if err != nil {
t.Fatalf("ListMine dist_x err: %v", err)
}
if len(distItems) != 1 || distItems[0].ID != l2.ID {
t.Fatalf("expected only dist listing %d, got %v", l2.ID, distItems)
}
// 2. 主站查询只返回主站商品
mainItems, err := repo.ListMine(context.Background(), user.ID, "")
if err != nil {
t.Fatalf("ListMine main err: %v", err)
}
if len(mainItems) != 1 || mainItems[0].ID != l1.ID {
t.Fatalf("expected only main listing %d, got %v", l1.ID, mainItems)
}
// 3. 不传站点时兼容返回全部
allItems, err := repo.ListMine(context.Background(), user.ID)
if err != nil {
t.Fatalf("ListMine all err: %v", err)
}
if len(allItems) != 2 {
t.Fatalf("expected 2 listings, got %d", len(allItems))
}
}
func TestCreateDistributionListingUsesMainSiteReceiveRatio(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("migrate err: %v", err)
}
svc := NewService(NewRepository(db, nil), stubConfigReader{})
user := model.User{Phone: "18800000021", Nickname: "分销收号卖家", RealnameStatus: "verified", Status: "active"}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
site := model.DistributionSite{
SiteCode: "dist_ratio_1",
SiteName: "收号比例站",
Domain: "ratio.example.com",
Status: "active",
DisplayAccountEnabled: true,
}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
dto, err := svc.Create(context.Background(), user.ID, CreateRequest{
Title: "分销收号商品",
ServerRegion: "QQ",
LoginPlatform: "PC",
RankLevel: "钻石",
HafCoinAmount: 100_000_000,
PriceCent: 100,
DepositAmountCent: 15000,
ScreenshotURLS: []string{"https://example.com/ratio.png"},
AssetSummary: map[string]any{
"fire_level": 50,
"season_insurance": "3*3",
"stamina_level": "7级",
"load_level": "7级",
"daily_loss_m": 10,
"online_time": map[string]any{
"start": "09:00",
"end": "23:00",
},
"price_breakdown": map[string]any{
"seller_ratio": 10.0,
"seller_total_price": 1.0,
"buyer_total_price": 1.0,
},
},
AgreedVirtualAssetSale: true,
AgreedSellerAgreement: true,
}, "dist_ratio_1")
if err != nil {
t.Fatalf("Create failed: %v", err)
}
pricing := ReceivePricingFromAssetSummary(mustMarshalSummary(t, dto.AssetSummary))
if pricing.SellerRatio != 43 {
t.Fatalf("distribution receive seller_ratio = %v, want 43 from main-site ratio_config", pricing.SellerRatio)
}
if pricing.SellerTotalPriceCent != 23260 {
t.Fatalf("seller_total_price_cent = %d, want 23260", pricing.SellerTotalPriceCent)
}
if dto.PriceCent != 26320 {
t.Fatalf("listing PriceCent = %d, want 26320 from main-site sale price", dto.PriceCent)
}
if dto.DistributionSiteCode != "dist_ratio_1" {
t.Fatalf("DistributionSiteCode = %s, want dist_ratio_1", dto.DistributionSiteCode)
}
}
func TestCreateDistributionListingAppliesSiteReceiveRatioDelta(t *testing.T) {
db := database.NewTestDB()
if err := database.MigrateListingLifecycleTestSchema(db); err != nil {
t.Fatalf("migrate err: %v", err)
}
svc := NewService(NewRepository(db, nil), stubConfigReader{})
user := model.User{Phone: "18800000022", Nickname: "分销下调卖家", RealnameStatus: "verified", Status: "active"}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
site := model.DistributionSite{
SiteCode: "dist_ratio_down",
SiteName: "下调收号站",
Domain: "ratio-down.example.com",
Status: "active",
DisplayAccountEnabled: true,
ReceiveRatioDelta: 3,
}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
dto, err := svc.Create(context.Background(), user.ID, CreateRequest{
Title: "分销下调收号",
ServerRegion: "QQ",
LoginPlatform: "PC",
RankLevel: "钻石",
HafCoinAmount: 100_000_000,
PriceCent: 100,
DepositAmountCent: 15000,
ScreenshotURLS: []string{"https://example.com/ratio-down.png"},
AssetSummary: map[string]any{
"fire_level": 50,
"season_insurance": "3*3",
"stamina_level": "7级",
"load_level": "7级",
"daily_loss_m": 10,
"online_time": map[string]any{
"start": "09:00",
"end": "23:00",
},
"price_breakdown": map[string]any{
"seller_ratio": 50.0,
},
},
AgreedVirtualAssetSale: true,
AgreedSellerAgreement: true,
}, "dist_ratio_down")
if err != nil {
t.Fatalf("Create failed: %v", err)
}
pricing := ReceivePricingFromAssetSummary(mustMarshalSummary(t, dto.AssetSummary))
if pricing.SellerRatio != 40 {
t.Fatalf("seller_ratio = %v, want 40 after site delta 3 below main-site 43", pricing.SellerRatio)
}
}
func mustMarshalSummary(t *testing.T, summary map[string]any) []byte {
t.Helper()
raw, err := json.Marshal(summary)
if err != nil {
t.Fatal(err)
}
return raw
}