784 lines
32 KiB
Go
784 lines
32 KiB
Go
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("/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/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())
|
||
}
|
||
|
||
// 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())
|
||
}
|
||
|
||
// 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{}
|
||
}
|