Files
hfb_sys/backend/internal/modules/listing/service_test.go
T
yml2213 cbd5412315 test: 后端测试从 SQLite 全面切换到 MySQL,与生产环境对齐
测试库不再使用 SQLite 内存库,改为每个测试包独立的 MySQL 库,
表结构由 backend/migrations 的真实迁移建立。

基础设施
- 新增 internal/database/testdb.go:NewTestDB() 保持原签名(28 处调用零改动),
  内部连接 MySQL 测试库,首次调用重建库并执行全部真实迁移,之后每次调用清空全表
- 库名 = 测试库基础名 + 包目录名,同包共享、跨包隔离,使 go test ./... 可并发执行
  (共用一库时各包会互相 DROP/CREATE 导致 database exists)
- 新增 NewDryRunDB():MySQL 方言 + DryRun,供断言 SQL 形态的 5 处测试使用
- 新增 AutoMigrateIfMissing():只对不存在的表 AutoMigrate,
  避免改动迁移定义的索引与约束
- 新增 scripts/prepare-test-db.sh:建库与通配授权,可重复执行
- 33 处 sqlite.Open 全部替换,26 个测试文件改用 MySQL
- 删除 46 处手写 SQLite 方言 DDL(AUTOINCREMENT / INTEGER PRIMARY KEY / TEXT),
  表统一由真实迁移建立
- go.mod 移除 gorm.io/driver/sqlite 与 mattn/go-sqlite3
- 删除生产代码里 distribution/repository.go 的 sqlite 方言分支(strftime)

对齐环境后暴露并修复的真实问题
- DATE(col) 在 MySQL 返回 DATE 类型,使 Date string 字段变成 RFC3339 时间串,
  大盘日期会带出时间部分;28 处改为 DATE_FORMAT(col, '%Y-%m-%d')
- 系统自动创建分销结算批次时 created_by 写入 0,违反指向 admin_users 的外键,
  生产会直接插入失败;改为写 NULL(新增 adminRef helper)
- MySQL DATETIME 仅到秒,静默截断 Go 侧写入的时间,
  导致租用时长计算与结算时点回读比对偏差;
  rental_orders 的 9 个业务时间列提到 DATETIME(6)
- 测试数据补齐严格模式要求的 NOT NULL 列、修正超出列长的取值、补齐外键父记录

迁移
- 000065 的 ALTER TABLE rental_orders 一并完成业务时间列精度调整,
  feature 侧不再单开迁移文件

脚本与文档
- check.sh / check-backend-coverage.sh 去掉 -p 1 串行与 SQLite 相关注释
- 更新 docs/Repository层测试补充总结.md 的测试运行说明
2026-09-10 14:34:01 +08:00

1011 lines
32 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 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))
}
}