- 新增端到端测试场景: - 场景 1: 主站收号分站加价售卖与批次结算全链路 - 场景 2: 分站收号主站售卖、独立鉴权、大盘统计与提现全流程 - 场景 3: 三方模式跨站流转、专属支付通道自动路由与双站批次独立结算 - 场景 4: 分站自收自售、3天冷却期自动释放及幂等防护 - 场景 5: 结算后退款回冲金库钱包、掏空后透支扣减与仲裁风险隔离 - 场景 6: 一键开站、成员JWT鉴权、品牌个性化、三模解析与货架同步加价 - 新增 distribution/handler_integration_test.go:覆盖 30+ 分销 HTTP 接口(前台公开端点、分站管理员登录/信息端点、站长运营端点及总台管理端点) - 修复并对齐底层逻辑: - FindOrder & ListOrders:多参与方按 PartyID 准确隔离各站收益与提价统计 - SnapshotPaidOrderTx:规范多方模式下 PartyTypeSite 的 SiteID/PartyID - 增加 ErrInsufficientBalance 哨兵错误并在 handler 映射为 400
737 lines
30 KiB
Go
737 lines
30 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)
|
|
}
|
|
|
|
// 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}}
|
|
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())
|
|
}
|
|
|
|
// 获取生成的 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)
|
|
}
|
|
|
|
// 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. 批量调价
|
|
batchMarkupReq := BatchUpdateAccountMarkupRequest{
|
|
ListingIDs: []uint64{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())
|
|
}
|
|
|
|
// 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{}
|
|
}
|