Files
hfb_sys/backend/internal/modules/distribution/handler_integration_test.go
T

784 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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{}
}