package distribution import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" "gorm.io/datatypes" "gorm.io/gorm" "hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/auth" ) type handlerTestEnv struct { db *gorm.DB repo *Repository service *Service handler *Handler jwtMgr *auth.JWTManager router *gin.Engine } func setupHandlerTestEnv(t *testing.T) *handlerTestEnv { t.Helper() gin.SetMode(gin.TestMode) db := distributionTestDB(t) repo := NewRepository(db) jwtMgr := auth.NewJWTManager("test_secret_key_32_bytes_handler_test") svc := NewService(repo, jwtMgr) h := NewHandler(svc) r := gin.New() r.Use(gin.Recovery()) // 模拟上下文中间件注入 adminID authMiddleware := func(c *gin.Context) { adminIDStr := c.GetHeader("X-Mock-Admin-ID") if adminIDStr != "" { var adminID uint64 fmt.Sscanf(adminIDStr, "%d", &adminID) c.Set(middleware.ContextAdminID, adminID) } c.Next() } // 1. 公开路由 pub := r.Group("/api/v1/distribution") { pub.GET("/site-info", h.GetPublicSiteInfo) pub.GET("/check-domain", h.CheckDomain) pub.GET("/sites/:id/listings", h.ListPublicSiteListings) pub.POST("/auth/login", h.Login) } // 2. 站点商户/操作员受保护路由 siteProtected := r.Group("/api/v1/distribution", authMiddleware) { siteProtected.GET("/auth/me", h.Me) siteProtected.POST("/auth/logout", h.Logout) siteProtected.GET("/sites/:id/wallet", h.GetSiteWallet) siteProtected.POST("/sites/:id/withdrawals", h.ApplySiteWithdrawal) siteProtected.GET("/sites/:id/withdrawals", h.ListSiteWithdrawals) siteProtected.GET("/sites/:id/analytics", h.GetDashboardAnalytics) siteProtected.PUT("/sites/:id", h.UpdateSite) siteProtected.PUT("/sites/:id/branding", h.UpdateSiteBranding) siteProtected.POST("/sites/:id/sync-accounts", h.SyncSiteAccounts) siteProtected.GET("/received-listings", h.ListReceivedListings) siteProtected.PUT("/received-listings/:id/status", h.UpdateReceivedListingStatus) siteProtected.PUT("/accounts/:id/markup", h.UpdateAccountMarkup) siteProtected.POST("/accounts/batch-markup", h.BatchUpdateAccountMarkup) siteProtected.POST("/accounts/batch-display", h.BatchUpdateAccountDisplay) } // 3. 总台管理路由 adm := r.Group("/api/v1/admin/distribution", authMiddleware) { adm.GET("/sites", h.ListSites) adm.GET("/site-defaults", h.GetSiteDefaults) adm.GET("/sites/:id", h.GetSite) adm.POST("/sites", h.CreateSite) adm.POST("/sites/onboard", h.OnboardSite) adm.PUT("/sites/:id", h.UpdateSite) adm.GET("/rules", h.ListRules) adm.POST("/rules", h.CreateRule) adm.PUT("/rules/:id/status", h.UpdateRuleStatus) adm.GET("/orders", h.ListOrders) adm.GET("/orders/:id", h.FindOrder) adm.POST("/orders/:id/financial-snapshot", h.CreateFinancialSnapshot) adm.GET("/payment-channels", h.ListPaymentChannels) adm.POST("/payment-channels", h.BindPaymentChannel) adm.DELETE("/payment-channels/:id", h.UnbindPaymentChannel) adm.GET("/settlement-batches", h.ListSettlementBatches) adm.POST("/settlement-batches", h.CreateSettlementBatch) adm.GET("/settlement-batches/:id/items", h.ListSettlementItems) adm.POST("/settlement-batches/:id/settle", h.MarkSettlementBatch) adm.POST("/settlement-batches/:id/cancel", h.CancelSettlementBatch) } return &handlerTestEnv{ db: db, repo: repo, service: svc, handler: h, jwtMgr: jwtMgr, router: r, } } func (env *handlerTestEnv) request(method, path string, body any, adminID uint64, extraHeaders map[string]string) *httptest.ResponseRecorder { var bodyReader *bytes.Reader if body != nil { b, _ := json.Marshal(body) bodyReader = bytes.NewReader(b) } else { bodyReader = bytes.NewReader([]byte{}) } req, _ := http.NewRequest(method, path, bodyReader) req.Header.Set("Content-Type", "application/json") if adminID > 0 { req.Header.Set("X-Mock-Admin-ID", fmt.Sprintf("%d", adminID)) } for k, v := range extraHeaders { req.Header.Set(k, v) } w := httptest.NewRecorder() env.router.ServeHTTP(w, req) return w } // TestHandler_PublicEndpoints 验证所有公开端点(无需登录): // 1. /api/v1/distribution/site-info (通过 site_code, domain, X-Distribution-Site-Code 解析及 404) // 2. /api/v1/distribution/sites/:id/listings (正常上架商品查询、分页、DisplayAccountEnabled=false 时返回空列表) func TestHandler_PublicEndpoints(t *testing.T) { env := setupHandlerTestEnv(t) now := time.Now() site := model.DistributionSite{ SiteCode: "pub_site_alpha", SiteName: "前台测试站", Domain: "pub.example.com", Status: "active", DisplayAccountEnabled: true, } if err := env.db.Create(&site).Error; err != nil { t.Fatal(err) } // 1. GetPublicSiteInfo 验证 // 1.1 参数 ?site_code= w1 := env.request("GET", "/api/v1/distribution/site-info?site_code=pub_site_alpha", nil, 0, nil) if w1.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w1.Code, w1.Body.String()) } var resp1 struct { Code string `json:"code"` Data PublicSiteInfoDTO `json:"data"` } json.Unmarshal(w1.Body.Bytes(), &resp1) if resp1.Code != "ok" || resp1.Data.SiteCode != "pub_site_alpha" { t.Fatalf("unexpected resp1: %+v", resp1) } // 1.2 参数 ?domain= w2 := env.request("GET", "/api/v1/distribution/site-info?domain=pub.example.com", nil, 0, nil) if w2.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w2.Code) } // 1.3 请求头 X-Distribution-Site-Code w3 := env.request("GET", "/api/v1/distribution/site-info", nil, 0, map[string]string{ "X-Distribution-Site-Code": "pub_site_alpha", }) if w3.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w3.Code) } // 1.4 不存在的站点返回 404 w4 := env.request("GET", "/api/v1/distribution/site-info?site_code=non_existent", nil, 0, nil) if w4.Code != http.StatusNotFound { t.Fatalf("expected 404 for not found site, got %d: %s", w4.Code, w4.Body.String()) } // 1.5 CheckDomain 验证(Caddy on_demand_tls ask 接口) // 活跃站点域名返回 200 "ok" w5 := env.request("GET", "/api/v1/distribution/check-domain?domain=pub.example.com", nil, 0, nil) if w5.Code != http.StatusOK || w5.Body.String() != "ok" { t.Fatalf("expected 200 ok, got %d: %s", w5.Code, w5.Body.String()) } // 带端口号时正常截断并校验通过 w5Port := env.request("GET", "/api/v1/distribution/check-domain?domain=pub.example.com:443", nil, 0, nil) if w5Port.Code != http.StatusOK || w5Port.Body.String() != "ok" { t.Fatalf("expected 200 ok with port, got %d: %s", w5Port.Code, w5Port.Body.String()) } // 未绑定或不存在的域名返回 404 w6 := env.request("GET", "/api/v1/distribution/check-domain?domain=unregistered.com", nil, 0, nil) if w6.Code != http.StatusNotFound { t.Fatalf("expected 404 for unregistered domain, got %d: %s", w6.Code, w6.Body.String()) } // 空域名返回 400 w7 := env.request("GET", "/api/v1/distribution/check-domain", nil, 0, nil) if w7.Code != http.StatusBadRequest { t.Fatalf("expected 400 for empty domain, got %d: %s", w7.Code, w7.Body.String()) } // 2. ListPublicSiteListings 验证 acc := model.GameAccount{ Title: "极品号-1", GameName: "cfhd", HafCoinAmount: 50_000_000, Status: "active", } env.db.Create(&acc) listing := model.RentalListing{ ListingNo: "L-PUB-1", AccountID: acc.ID, OwnerID: 1, PriceCent: 4000, Status: "published", ReviewStatus: "approved", } env.db.Create(&listing) env.db.Create(&model.DistributionSiteAccount{ SiteID: site.ID, AccountID: acc.ID, ListingID: listing.ID, DisplayStatus: "enabled", ShelfRatioDelta: 10, }) wList := env.request("GET", fmt.Sprintf("/api/v1/distribution/sites/%s/listings?page=1&page_size=10", site.SiteCode), nil, 0, nil) if wList.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", wList.Code, wList.Body.String()) } var listResp struct { Code string `json:"code"` Data PublicDistributionListingsResult `json:"data"` } json.Unmarshal(wList.Body.Bytes(), &listResp) if listResp.Data.Total != 1 || len(listResp.Data.Items) != 1 { t.Fatalf("expected 1 listing, got total=%d items=%d", listResp.Data.Total, len(listResp.Data.Items)) } // 2.1 关闭展示开关时返回空列表 env.db.Model(&site).Update("display_account_enabled", false) wListClosed := env.request("GET", fmt.Sprintf("/api/v1/distribution/sites/%s/listings", site.SiteCode), nil, 0, nil) if wListClosed.Code != http.StatusOK { t.Fatalf("expected 200, got %d", wListClosed.Code) } json.Unmarshal(wListClosed.Body.Bytes(), &listResp) if listResp.Data.Total != 0 || len(listResp.Data.Items) != 0 { t.Fatalf("expected empty listings when display_account_enabled is false, got %+v", listResp.Data) } _ = now } // TestHandler_AuthEndpoints 验证分销身份认证相关路由: // 1. POST /api/v1/distribution/auth/login (账号密码登录成功与错误密码) // 2. GET /api/v1/distribution/auth/me (查询自身账号身份、所属分站) // 3. POST /api/v1/distribution/auth/logout (登出) func TestHandler_AuthEndpoints(t *testing.T) { env := setupHandlerTestEnv(t) // 先通过 OnboardSite 创建完整的分站管理员账号 onboardResp, err := env.service.OnboardSite(t.Context(), OnboardSiteRequest{ SiteCode: "auth_test_site", SiteName: "认证测试站", Domain: "auth.example.com", AdminUsername: "auth_op_user", AdminPassword: "SecurePassword123!", AdminNickname: "认证站长", MemberType: "operator", }) if err != nil { t.Fatal(err) } // 1. 正确登录 loginReq := DistributionLoginRequest{ Username: "auth_op_user", Password: "SecurePassword123!", } wLogin := env.request("POST", "/api/v1/distribution/auth/login", loginReq, 0, nil) if wLogin.Code != http.StatusOK { t.Fatalf("login expected 200, got %d: %s", wLogin.Code, wLogin.Body.String()) } var loginResp struct { Code string `json:"code"` Data DistributionLoginResponse `json:"data"` } json.Unmarshal(wLogin.Body.Bytes(), &loginResp) if loginResp.Data.Token == "" || loginResp.Data.Member.Username != "auth_op_user" { t.Fatalf("invalid login response: %+v", loginResp) } // 2. 错误密码登录拦截 badLoginReq := DistributionLoginRequest{ Username: "auth_op_user", Password: "WrongPassword!", } wBadLogin := env.request("POST", "/api/v1/distribution/auth/login", badLoginReq, 0, nil) if wBadLogin.Code != http.StatusBadRequest { t.Fatalf("bad password expected 400, got %d: %s", wBadLogin.Code, wBadLogin.Body.String()) } // 3. GET /api/v1/distribution/auth/me // 带分站管理员身份 wMe := env.request("GET", "/api/v1/distribution/auth/me", nil, onboardResp.Member.AdminID, nil) if wMe.Code != http.StatusOK { t.Fatalf("me expected 200, got %d: %s", wMe.Code, wMe.Body.String()) } var meResp struct { Code string `json:"code"` Data DistributionCurrentMemberDTO `json:"data"` } json.Unmarshal(wMe.Body.Bytes(), &meResp) if meResp.Data.Username != "auth_op_user" || meResp.Data.SiteCode != "auth_test_site" { t.Fatalf("unexpected me response: %+v", meResp) } // 4. 未登录访问 /api/v1/distribution/auth/me 返回 401 wMeUnauth := env.request("GET", "/api/v1/distribution/auth/me", nil, 0, nil) if wMeUnauth.Code != http.StatusUnauthorized { t.Fatalf("unauth me expected 401, got %d", wMeUnauth.Code) } // 5. POST /api/v1/distribution/auth/logout wLogout := env.request("POST", "/api/v1/distribution/auth/logout", nil, onboardResp.Member.AdminID, nil) if wLogout.Code != http.StatusOK { t.Fatalf("logout expected 200, got %d", wLogout.Code) } } // TestHandler_SiteOperatorEndpoints 验证分销站点管理员操作自身资源的端点: // 1. GET /api/v1/distribution/sites/:id/wallet (查询站点金库) // 2. POST /api/v1/distribution/sites/:id/withdrawals (申请金库提现) // 3. GET /api/v1/distribution/sites/:id/withdrawals (查询提现申请记录) // 4. GET /api/v1/distribution/sites/:id/analytics (站点数据大盘看板) // 5. PUT /api/v1/distribution/sites/:id/branding (站点个性化装修与配置) // 6. POST /api/v1/distribution/sites/:id/sync-accounts (商品货架全量/选品同步) // 7. PUT /api/v1/distribution/accounts/:id/markup (单品加价调价) // 8. POST /api/v1/distribution/accounts/batch-markup (批量调价) // 9. GET /api/v1/distribution/received-listings (收号资产列表) // 10. PUT /api/v1/distribution/received-listings/:id/status (收号资产状态启停) // 11. 鉴权越权隔离:操作员跨站访问其他分站资源一律返回 403 Forbidden func TestHandler_SiteOperatorEndpoints(t *testing.T) { env := setupHandlerTestEnv(t) // 创建站点 A 及操作员 A siteA := model.DistributionSite{SiteCode: "op_site_a", SiteName: "操作分站A", Domain: "opa.example.com", Status: "active", DisplayAccountEnabled: true} env.db.Create(&siteA) adminA := model.AdminUser{Username: "op_user_a", Status: "active"} env.db.Create(&adminA) env.db.Create(&model.DistributionSiteMember{SiteID: siteA.ID, AdminUserID: adminA.ID, MemberType: "operator", Status: "active"}) // 站点 A 金库钱包初始 50.00 元 (5000 分) env.db.Create(&model.WalletAccount{UserID: 0, DistributionSiteID: siteA.ID, AvailableBalanceCent: 5000, Status: "active"}) // 创建站点 B 及操作员 B (用于跨站越权测试) siteB := model.DistributionSite{SiteCode: "op_site_b", SiteName: "操作分站B", Domain: "opb.example.com", Status: "active"} env.db.Create(&siteB) adminB := model.AdminUser{Username: "op_user_b", Status: "active"} env.db.Create(&adminB) env.db.Create(&model.DistributionSiteMember{SiteID: siteB.ID, AdminUserID: adminB.ID, MemberType: "operator", Status: "active"}) env.db.Create(&model.WalletAccount{UserID: 0, DistributionSiteID: siteB.ID, AvailableBalanceCent: 2000, Status: "active"}) // 1. 查询钱包余额 wWallet := env.request("GET", fmt.Sprintf("/api/v1/distribution/sites/%d/wallet", siteA.ID), nil, adminA.ID, nil) if wWallet.Code != http.StatusOK { t.Fatalf("wallet expected 200, got %d: %s", wWallet.Code, wWallet.Body.String()) } var walletResp struct { Code string `json:"code"` Data SiteWalletDTO `json:"data"` } json.Unmarshal(wWallet.Body.Bytes(), &walletResp) if walletResp.Data.AvailableBalanceCent != 5000 { t.Fatalf("wallet balance = %d, want 5000", walletResp.Data.AvailableBalanceCent) } // 越权拦截:操作员 B 查站点 A 的钱包必须返回 403 wCrossWallet := env.request("GET", fmt.Sprintf("/api/v1/distribution/sites/%d/wallet", siteA.ID), nil, adminB.ID, nil) if wCrossWallet.Code != http.StatusForbidden { t.Fatalf("cross-site wallet expected 403, got %d: %s", wCrossWallet.Code, wCrossWallet.Body.String()) } // 2. 申请提现 wdReq := ApplySiteWithdrawalRequest{ AmountCent: 1500, AccountType: "alipay", AccountName: "站长张三", AccountNo: "zhangsan@alipay.com", } wWd := env.request("POST", fmt.Sprintf("/api/v1/distribution/sites/%d/withdrawals", siteA.ID), wdReq, adminA.ID, nil) if wWd.Code != http.StatusCreated && wWd.Code != http.StatusOK { t.Fatalf("apply withdrawal expected 201/200, got %d: %s", wWd.Code, wWd.Body.String()) } // 提现金额超过余额应拦截 (400) overWdReq := ApplySiteWithdrawalRequest{ AmountCent: 999999, AccountType: "alipay", AccountName: "站长张三", AccountNo: "zhangsan@alipay.com", } wOverWd := env.request("POST", fmt.Sprintf("/api/v1/distribution/sites/%d/withdrawals", siteA.ID), overWdReq, adminA.ID, nil) if wOverWd.Code != http.StatusBadRequest { t.Fatalf("over withdrawal expected 400, got %d: %s", wOverWd.Code, wOverWd.Body.String()) } // 3. 查询提现列表 wWdList := env.request("GET", fmt.Sprintf("/api/v1/distribution/sites/%d/withdrawals?page=1&page_size=10", siteA.ID), nil, adminA.ID, nil) if wWdList.Code != http.StatusOK { t.Fatalf("list withdrawals expected 200, got %d: %s", wWdList.Code, wWdList.Body.String()) } // 4. 查询数据看板 wAnalytics := env.request("GET", fmt.Sprintf("/api/v1/distribution/sites/%d/analytics", siteA.ID), nil, adminA.ID, nil) if wAnalytics.Code != http.StatusOK { t.Fatalf("analytics expected 200, got %d: %s", wAnalytics.Code, wAnalytics.Body.String()) } // 5. 更新装修与品牌 logo := "https://example.com/new_logo.png" favicon := "https://example.com/new_fav.ico" footerJSON := `{"icp":"京ICP备123456号"}` brandReq := UpdateSiteBrandingRequest{ LogoURL: &logo, FaviconURL: &favicon, FooterInfoJSON: &footerJSON, } wBrand := env.request("PUT", fmt.Sprintf("/api/v1/distribution/sites/%d/branding", siteA.ID), brandReq, adminA.ID, nil) if wBrand.Code != http.StatusOK { t.Fatalf("branding expected 200, got %d: %s", wBrand.Code, wBrand.Body.String()) } delta := 2.5 wRatio := env.request("PUT", fmt.Sprintf("/api/v1/distribution/sites/%d", siteA.ID), UpdateSiteRequest{ReceiveRatioDelta: &delta}, adminA.ID, nil) if wRatio.Code != http.StatusOK { t.Fatalf("receive ratio expected 200, got %d: %s", wRatio.Code, wRatio.Body.String()) } var ratioResp struct { Code string `json:"code"` Data SiteDTO `json:"data"` } json.Unmarshal(wRatio.Body.Bytes(), &ratioResp) if ratioResp.Data.ReceiveRatioDelta != 2.5 { t.Fatalf("receive_ratio_delta = %v, want 2.5", ratioResp.Data.ReceiveRatioDelta) } hackedName := "越权改名" wHackName := env.request("PUT", fmt.Sprintf("/api/v1/distribution/sites/%d", siteA.ID), UpdateSiteRequest{SiteName: &hackedName}, adminA.ID, nil) if wHackName.Code != http.StatusOK { t.Fatalf("operator privileged field expected 200 with ignore, got %d: %s", wHackName.Code, wHackName.Body.String()) } json.Unmarshal(wHackName.Body.Bytes(), &ratioResp) if ratioResp.Data.SiteName != siteA.SiteName { t.Fatalf("operator must not rename site, got %q", ratioResp.Data.SiteName) } wCrossRatio := env.request("PUT", fmt.Sprintf("/api/v1/distribution/sites/%d", siteA.ID), UpdateSiteRequest{ReceiveRatioDelta: &delta}, adminB.ID, nil) if wCrossRatio.Code != http.StatusForbidden { t.Fatalf("cross-site receive ratio expected 403, got %d: %s", wCrossRatio.Code, wCrossRatio.Body.String()) } // 6. 同步主站商品货架 opOwner := model.User{Phone: "13800000077", Status: "active"} env.db.Create(&opOwner) masterAcc := model.GameAccount{OwnerID: opOwner.ID, Title: "同步母账号", GameName: "cfhd", HafCoinAmount: 10_000_000, Status: "active"} env.db.Create(&masterAcc) masterListing := model.RentalListing{ListingNo: "L-SYNC-1", AccountID: masterAcc.ID, OwnerID: opOwner.ID, PriceCent: 3000, Status: "published", ReviewStatus: "approved"} env.db.Create(&masterListing) syncReq := SyncSiteAccountsRequest{ ListingIDs: []uint64{masterListing.ID}, CleanStaleListings: true, DefaultShelfRatioDelta: 12.5, } wSync := env.request("POST", fmt.Sprintf("/api/v1/distribution/sites/%d/sync-accounts", siteA.ID), syncReq, adminA.ID, nil) if wSync.Code != http.StatusOK { t.Fatalf("sync accounts expected 200, got %d: %s", wSync.Code, wSync.Body.String()) } var syncResp struct { Code string `json:"code"` Data SyncSiteAccountsResult `json:"data"` } json.Unmarshal(wSync.Body.Bytes(), &syncResp) if syncResp.Data.SyncedCount != 1 { t.Fatalf("expected 1 synced, got %+v", syncResp.Data) } // 获取生成的 DSA 记录并验证默认加价生效 var dsa model.DistributionSiteAccount if err := env.db.Where("site_id = ? AND listing_id = ?", siteA.ID, masterListing.ID).First(&dsa).Error; err != nil { t.Fatal(err) } if dsa.ShelfRatioDelta != 12.5 { t.Fatalf("expected shelf_ratio_delta=12.5, got %v", dsa.ShelfRatioDelta) } // 验证负数加价比例报错 400 wNegativeSync := env.request("POST", fmt.Sprintf("/api/v1/distribution/sites/%d/sync-accounts", siteA.ID), SyncSiteAccountsRequest{ DefaultShelfRatioDelta: -10, }, adminA.ID, nil) if wNegativeSync.Code != http.StatusBadRequest { t.Fatalf("expected 400 for negative delta, got %d", wNegativeSync.Code) } // 7. 单品调价 markupReq := UpdateAccountMarkupRequest{ShelfRatioDelta: 15} wMarkup := env.request("PUT", fmt.Sprintf("/api/v1/distribution/accounts/%d/markup", dsa.ID), markupReq, adminA.ID, nil) if wMarkup.Code != http.StatusOK { t.Fatalf("update markup expected 200, got %d: %s", wMarkup.Code, wMarkup.Body.String()) } // 8. 批量调价(支持 Items 个性化与统一加价) batchMarkupReq := BatchUpdateAccountMarkupRequest{ Items: []AccountMarkupItem{ {ListingID: masterListing.ID, ShelfRatioDelta: 20}, }, } wBatchMarkup := env.request("POST", "/api/v1/distribution/accounts/batch-markup", batchMarkupReq, adminA.ID, nil) if wBatchMarkup.Code != http.StatusOK { t.Fatalf("batch markup expected 200, got %d: %s", wBatchMarkup.Code, wBatchMarkup.Body.String()) } // 8.1 批量启停下架(POST /api/v1/distribution/accounts/batch-display) batchDisplayReq := BatchUpdateAccountDisplayRequest{ DisplayIDs: []uint64{dsa.ID}, TargetStatus: "disabled", } wBatchDisplay := env.request("POST", "/api/v1/distribution/accounts/batch-display", batchDisplayReq, adminA.ID, nil) if wBatchDisplay.Code != http.StatusOK { t.Fatalf("batch display expected 200, got %d: %s", wBatchDisplay.Code, wBatchDisplay.Body.String()) } var dsaCheck model.DistributionSiteAccount env.db.First(&dsaCheck, dsa.ID) if dsaCheck.DisplayStatus != "disabled" { t.Fatalf("expected dsa disabled, got %s", dsaCheck.DisplayStatus) } // 恢复展示 batchDisplayReq.TargetStatus = "enabled" wBatchDisplayEnable := env.request("POST", "/api/v1/distribution/accounts/batch-display", batchDisplayReq, adminA.ID, nil) if wBatchDisplayEnable.Code != http.StatusOK { t.Fatalf("batch display enable expected 200, got %d: %s", wBatchDisplayEnable.Code, wBatchDisplayEnable.Body.String()) } // 9. 查询收号资产列表 wRecList := env.request("GET", "/api/v1/distribution/received-listings?page=1&size=10", nil, adminA.ID, nil) if wRecList.Code != http.StatusOK { t.Fatalf("received listings expected 200, got %d: %s", wRecList.Code, wRecList.Body.String()) } // 10. 更新收号商品上下架状态 ownAcc := model.GameAccount{OwnerID: opOwner.ID, Title: "自收自管独立账号", GameName: "cfhd", HafCoinAmount: 15_000_000, Status: "active"} env.db.Create(&ownAcc) ownListing := model.RentalListing{ ListingNo: "L-OWN-1", AccountID: ownAcc.ID, OwnerID: opOwner.ID, PriceCent: 3000, Status: "published", ReviewStatus: "approved", DistributionSiteID: &siteA.ID, } if err := env.db.Create(&ownListing).Error; err != nil { t.Fatal(err) } statusReq := UpdateReceivedListingStatusRequest{Status: "offline"} wStatus := env.request("PUT", fmt.Sprintf("/api/v1/distribution/received-listings/%d/status", ownListing.ID), statusReq, adminA.ID, nil) if wStatus.Code != http.StatusOK { t.Fatalf("update received listing status expected 200, got %d: %s", wStatus.Code, wStatus.Body.String()) } } // TestHandler_AdminEndpoints 验证平台超级管理员专用的管理端点: // 1. GET /api/v1/admin/distribution/sites (站点列表) // 2. GET /api/v1/admin/distribution/site-defaults (站点默认配置) // 3. GET /api/v1/admin/distribution/sites/:id (站点详情) // 4. POST /api/v1/admin/distribution/sites (创建站点) // 5. POST /api/v1/admin/distribution/sites/onboard (开通站点) // 6. PUT /api/v1/admin/distribution/sites/:id (更新站点) // 7. GET /api/v1/admin/distribution/rules (规则列表) // 8. POST /api/v1/admin/distribution/rules (创建新版本规则) // 9. PUT /api/v1/admin/distribution/rules/:id/status (启停规则) // 10. GET /api/v1/admin/distribution/orders (订单列表) // 11. GET /api/v1/admin/distribution/orders/:id (订单详情) // 12. POST /api/v1/admin/distribution/orders/:id/financial-snapshot (手动快照触发) // 13. GET /api/v1/admin/distribution/payment-channels (通道列表) // 14. POST /api/v1/admin/distribution/payment-channels (绑定通道) // 15. DELETE /api/v1/admin/distribution/payment-channels/:id (解绑通道) // 16. GET /api/v1/admin/distribution/settlement-batches (结算批次列表) // 17. POST /api/v1/admin/distribution/settlement-batches (创建结算批次) // 18. GET /api/v1/admin/distribution/settlement-batches/:id/items (批次明细列表) // 19. POST /api/v1/admin/distribution/settlement-batches/:id/settle (执行结算) // 20. POST /api/v1/admin/distribution/settlement-batches/:id/cancel (作废结算批次) func TestHandler_AdminEndpoints(t *testing.T) { env := setupHandlerTestEnv(t) now := time.Now() // 创建真实超级管理员账号并赋予 super_admin 角色 superAdmin := model.AdminUser{Username: "super_admin_suite", Status: "active"} env.db.Create(&superAdmin) role := model.Role{Name: "超级管理员", Code: "super_admin", Description: "超级管理员"} env.db.Create(&role) env.db.Create(&model.AdminUserRole{AdminID: superAdmin.ID, RoleID: role.ID}) superAdminID := superAdmin.ID // 1. ListSites wListSites := env.request("GET", "/api/v1/admin/distribution/sites", nil, superAdminID, nil) if wListSites.Code != http.StatusOK { t.Fatalf("ListSites expected 200, got %d: %s", wListSites.Code, wListSites.Body.String()) } // 2. GetSiteDefaults wDefaults := env.request("GET", "/api/v1/admin/distribution/site-defaults", nil, superAdminID, nil) if wDefaults.Code != http.StatusOK { t.Fatalf("GetSiteDefaults expected 200, got %d: %s", wDefaults.Code, wDefaults.Body.String()) } // 3. CreateSite enabled := true createReq := CreateSiteRequest{ SiteCode: "adm_site_x", SiteName: "总台创建站", Domain: "x.example.com", DisplayAccountEnabled: &enabled, } wCreate := env.request("POST", "/api/v1/admin/distribution/sites", createReq, superAdminID, nil) if wCreate.Code != http.StatusCreated && wCreate.Code != http.StatusOK { t.Fatalf("CreateSite expected 201/200, got %d: %s", wCreate.Code, wCreate.Body.String()) } var createdSiteResp struct { Code string `json:"code"` Data SiteDTO `json:"data"` } json.Unmarshal(wCreate.Body.Bytes(), &createdSiteResp) siteID := createdSiteResp.Data.ID // 4. GetSite wGetSite := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/sites/%d", siteID), nil, superAdminID, nil) if wGetSite.Code != http.StatusOK { t.Fatalf("GetSite expected 200, got %d: %s", wGetSite.Code, wGetSite.Body.String()) } // 5. UpdateSite newName := "总台更新站-改名" updateReq := UpdateSiteRequest{ SiteName: &newName, } wUpdateSite := env.request("PUT", fmt.Sprintf("/api/v1/admin/distribution/sites/%d", siteID), updateReq, superAdminID, nil) if wUpdateSite.Code != http.StatusOK { t.Fatalf("UpdateSite expected 200, got %d: %s", wUpdateSite.Code, wUpdateSite.Body.String()) } // 6. OnboardSite onboardReq := OnboardSiteRequest{ SiteCode: "adm_onboard_site", SiteName: "总台开通站", Domain: "onboard.example.com", AdminUsername: "onboard_manager", AdminPassword: "Password123!", AdminNickname: "开通站长", MemberType: "operator", } wOnboard := env.request("POST", "/api/v1/admin/distribution/sites/onboard", onboardReq, superAdminID, nil) if wOnboard.Code != http.StatusOK && wOnboard.Code != http.StatusCreated { t.Fatalf("OnboardSite expected 200/201, got %d: %s", wOnboard.Code, wOnboard.Body.String()) } // 7. ListRules wRules := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/rules?site_id=%d", siteID), nil, superAdminID, nil) if wRules.Code != http.StatusOK { t.Fatalf("ListRules expected 200, got %d: %s", wRules.Code, wRules.Body.String()) } // 8. CreateRule eff := now.Add(-time.Hour) createRuleReq := CreateRuleRequest{ SiteID: &siteID, Mode: ModeTwoPartyDistributorReceive, DistributorReceiveRatio: 35, PlatformSellRatio: 40, ServiceFeeValue: 25, ServiceFeeUnit: ServiceFeeUnitPercent, PaymentFeeBPS: 50, EffectiveFrom: eff, } wCreateRule := env.request("POST", "/api/v1/admin/distribution/rules", createRuleReq, superAdminID, nil) if wCreateRule.Code != http.StatusOK && wCreateRule.Code != http.StatusCreated { t.Fatalf("CreateRule expected 200/201, got %d: %s", wCreateRule.Code, wCreateRule.Body.String()) } var ruleResp struct { Code string `json:"code"` Data RuleDTO `json:"data"` } json.Unmarshal(wCreateRule.Body.Bytes(), &ruleResp) ruleID := ruleResp.Data.ID // 9. UpdateRuleStatus statusReq := UpdateRuleStatusRequest{Status: "disabled"} wRuleStatus := env.request("PUT", fmt.Sprintf("/api/v1/admin/distribution/rules/%d/status", ruleID), statusReq, superAdminID, nil) if wRuleStatus.Code != http.StatusOK { t.Fatalf("UpdateRuleStatus expected 200, got %d: %s", wRuleStatus.Code, wRuleStatus.Body.String()) } // 10. 构造测试订单并验证 ListOrders / FindOrder owner := model.User{Phone: "13800000088", Status: "active"} renter := model.User{Phone: "13900000088", Status: "active"} env.db.Create(&owner) env.db.Create(&renter) acc := model.GameAccount{Title: "总台测试账号", GameName: "cfhd", HafCoinAmount: 20_000_000, Status: "active"} env.db.Create(&acc) listing := model.RentalListing{ListingNo: "L-ADM-ORD-1", AccountID: acc.ID, OwnerID: owner.ID, PriceCent: 5000, Status: "published", ReviewStatus: "approved", DistributionSiteID: &siteID} env.db.Create(&listing) order := model.RentalOrder{ OrderNo: "ORD-ADM-1", ListingID: listing.ID, AccountID: acc.ID, OwnerID: owner.ID, RenterID: renter.ID, Status: "completed", RentAmountCent: 5000, OwnerRentAmountCent: 3000, DistributionSiteID: &siteID, } env.db.Create(&order) // 手动触发快照端点: POST /api/v1/admin/distribution/orders/:id/financial-snapshot snapReq := CreateFinancialSnapshotRequest{ SiteID: siteID, RuleVersionID: ruleID, GrossPaidAmountCent: 5000, Parties: []SnapshotPartyRequest{ { PartyRole: "distributor_receive", PartyType: PartyTypeSite, PartyID: siteID, Ratio: 35, IncomeAmountCent: 700, }, }, } wSnap := env.request("POST", fmt.Sprintf("/api/v1/admin/distribution/orders/%d/financial-snapshot", order.ID), snapReq, superAdminID, nil) if wSnap.Code != http.StatusOK && wSnap.Code != http.StatusCreated { t.Fatalf("CreateFinancialSnapshot expected 200/201, got %d: %s", wSnap.Code, wSnap.Body.String()) } // 11. ListOrders wOrders := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/orders?site_id=%d&page=1&size=10", siteID), nil, superAdminID, nil) if wOrders.Code != http.StatusOK { t.Fatalf("ListOrders expected 200, got %d: %s", wOrders.Code, wOrders.Body.String()) } // 12. FindOrder wFindOrder := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/orders/%d", order.ID), nil, superAdminID, nil) if wFindOrder.Code != http.StatusOK { t.Fatalf("FindOrder expected 200, got %d: %s", wFindOrder.Code, wFindOrder.Body.String()) } // 13. 支付通道管理 merchantCfg := model.PaymentMerchantConfig{Name: "测试通道", Provider: "mock", MerchantID: "MOCK_CH_1", PayWay: "WX", Status: "active", Environment: "sandbox"} env.db.Create(&merchantCfg) bindReq := BindPaymentChannelRequest{ ConfigID: merchantCfg.ID, Priority: 1, } wBind := env.request("POST", fmt.Sprintf("/api/v1/admin/distribution/payment-channels?site_id=%d", siteID), bindReq, superAdminID, nil) if wBind.Code != http.StatusOK && wBind.Code != http.StatusCreated { t.Fatalf("BindPaymentChannel expected 200/201, got %d: %s", wBind.Code, wBind.Body.String()) } var bindResp struct { Code string `json:"code"` Data PaymentChannelDTO `json:"data"` } json.Unmarshal(wBind.Body.Bytes(), &bindResp) channelID := bindResp.Data.ID wChannels := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/payment-channels?site_id=%d", siteID), nil, superAdminID, nil) if wChannels.Code != http.StatusOK { t.Fatalf("ListPaymentChannels expected 200, got %d", wChannels.Code) } wUnbind := env.request("DELETE", fmt.Sprintf("/api/v1/admin/distribution/payment-channels/%d", channelID), nil, superAdminID, nil) if wUnbind.Code != http.StatusOK { t.Fatalf("UnbindPaymentChannel expected 200, got %d: %s", wUnbind.Code, wUnbind.Body.String()) } // 14. 结算批次全流程端点测试 (Create -> List -> Items -> Settle / Cancel) // 初始化钱包以支撑入账 env.db.Create(&model.WalletAccount{UserID: 0, DistributionSiteID: siteID, Status: "active"}) batchReq := CreateSettlementBatchRequest{ SiteID: siteID, PeriodStart: now.Add(-24 * time.Hour), PeriodEnd: now.Add(24 * time.Hour), } wCreateBatch := env.request("POST", "/api/v1/admin/distribution/settlement-batches", batchReq, superAdminID, nil) if wCreateBatch.Code != http.StatusOK && wCreateBatch.Code != http.StatusCreated { t.Fatalf("CreateSettlementBatch expected 200/201, got %d: %s", wCreateBatch.Code, wCreateBatch.Body.String()) } var batchResp struct { Code string `json:"code"` Data SettlementBatchDTO `json:"data"` } json.Unmarshal(wCreateBatch.Body.Bytes(), &batchResp) batchID := batchResp.Data.ID // 查询批次列表 wBatches := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/settlement-batches?site_id=%d&page=1&size=10", siteID), nil, superAdminID, nil) if wBatches.Code != http.StatusOK { t.Fatalf("ListSettlementBatches expected 200, got %d", wBatches.Code) } // 查询批次明细 wItems := env.request("GET", fmt.Sprintf("/api/v1/admin/distribution/settlement-batches/%d/items", batchID), nil, superAdminID, nil) if wItems.Code != http.StatusOK { t.Fatalf("ListSettlementItems expected 200, got %d: %s", wItems.Code, wItems.Body.String()) } // 执行批次结算 wSettle := env.request("POST", fmt.Sprintf("/api/v1/admin/distribution/settlement-batches/%d/settle", batchID), nil, superAdminID, nil) if wSettle.Code != http.StatusOK { t.Fatalf("MarkSettlementBatch expected 200, got %d: %s", wSettle.Code, wSettle.Body.String()) } // 再次创建一个空批次用于测试作废 (Cancel) wCreateBatch2 := env.request("POST", "/api/v1/admin/distribution/settlement-batches", batchReq, superAdminID, nil) if wCreateBatch2.Code == http.StatusOK || wCreateBatch2.Code == http.StatusCreated { var batchResp2 struct { Code string `json:"code"` Data SettlementBatchDTO `json:"data"` } json.Unmarshal(wCreateBatch2.Body.Bytes(), &batchResp2) if batchResp2.Data.ID > 0 { wCancel := env.request("POST", fmt.Sprintf("/api/v1/admin/distribution/settlement-batches/%d/cancel", batchResp2.Data.ID), nil, superAdminID, nil) if wCancel.Code != http.StatusOK { t.Fatalf("CancelSettlementBatch expected 200, got %d: %s", wCancel.Code, wCancel.Body.String()) } } } _ = datatypes.JSON{} }