feat(distribution): 独立分销后台账号体系、多租户隔离与主站后台分销管理GUI

- 独立账号鉴权:分销后台支持独立登录页面、Bearer+Cookie 双模鉴权与租户资料接口
- 多租户隔离:分销商成员强制锁定自身站点数据范围,防止横向越权;平台超管享有全局视界
- 一键开通分销商:平台端支持一键录入站点、管理员账号、分配角色并原子化初始化佣金规则
- 主后台分销分组:在主站后台增加「分销管理」导航分组,提供站点管理、规则配置、订单监控与结算管理 GUI
- 门禁与联调:补充完整单测,全量代码门禁 check.sh 100% 通过,dev.sh 支持前后端与分销后台一键同启
This commit is contained in:
yml2213
2026-09-07 15:45:53 +08:00
parent 0c37c7e659
commit 550b42eb22
28 changed files with 3192 additions and 117 deletions
@@ -0,0 +1,151 @@
package distribution
import (
"context"
"testing"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/auth"
"golang.org/x/crypto/bcrypt"
)
func TestOnboardSiteAndLoginFlow(t *testing.T) {
db := distributionTestDB(t)
repo := NewRepository(db)
jwtMgr := auth.NewJWTManager("test_secret_32_bytes_long_keys_123")
svc := NewService(repo, jwtMgr)
ctx := context.Background()
// 1. Onboard new site
req := OnboardSiteRequest{
SiteCode: "gz_dist",
SiteName: "广州分销站",
Domain: "gz.distribution.com",
AdminUsername: "gz_operator",
AdminPassword: "Password123!",
AdminNickname: "广州运营小李",
MemberType: "operator",
DistributorReceiveRatio: 3.8,
PlatformSellRatio: 4.2,
ServiceFeeValue: 2.0,
}
resp, err := svc.OnboardSite(ctx, req)
if err != nil {
t.Fatalf("failed to onboard site: %v", err)
}
if resp.Site.SiteCode != "gz_dist" {
t.Fatalf("expected SiteCode gz_dist, got %s", resp.Site.SiteCode)
}
if resp.Member.Username != "gz_operator" {
t.Fatalf("expected Username gz_operator, got %s", resp.Member.Username)
}
if resp.Member.IsSuperadmin {
t.Fatalf("expected IsSuperadmin=false for site member")
}
if resp.Member.SiteID == nil || *resp.Member.SiteID != resp.Site.ID {
t.Fatalf("expected SiteID=%d, got %v", resp.Site.ID, resp.Member.SiteID)
}
if resp.Rule.DistributorReceiveRatio != 3.8 {
t.Fatalf("expected rule ratio 3.8, got %f", resp.Rule.DistributorReceiveRatio)
}
// 2. Duplicate site code or domain check
dupReq := req
dupReq.AdminUsername = "gz_operator_2"
if _, err := svc.OnboardSite(ctx, dupReq); err != ErrDuplicateSiteCodeOrDomain {
t.Fatalf("expected ErrDuplicateSiteCodeOrDomain, got %v", err)
}
// Duplicate username check
dupUserReq := req
dupUserReq.SiteCode = "gz_dist_2"
dupUserReq.Domain = "gz2.distribution.com"
if _, err := svc.OnboardSite(ctx, dupUserReq); err != ErrDuplicateUsername {
t.Fatalf("expected ErrDuplicateUsername, got %v", err)
}
// 3. Login with onboarded credentials
loginResp, err := svc.Login(ctx, DistributionLoginRequest{
Username: "gz_operator",
Password: "Password123!",
}, "127.0.0.1")
if err != nil {
t.Fatalf("failed to login: %v", err)
}
if loginResp.Token == "" {
t.Fatalf("expected token, got empty")
}
if loginResp.Member.SiteCode != "gz_dist" {
t.Fatalf("expected site code gz_dist, got %s", loginResp.Member.SiteCode)
}
// 4. Login with wrong password
_, err = svc.Login(ctx, DistributionLoginRequest{
Username: "gz_operator",
Password: "wrong_password",
}, "127.0.0.1")
if err != ErrInvalidCredential {
t.Fatalf("expected ErrInvalidCredential, got %v", err)
}
// 5. Login with non-existent user
_, err = svc.Login(ctx, DistributionLoginRequest{
Username: "non_existent",
Password: "Password123!",
}, "127.0.0.1")
if err != ErrInvalidCredential {
t.Fatalf("expected ErrInvalidCredential, got %v", err)
}
// 6. Test disabled user
if err := db.Model(&model.AdminUser{}).Where("username = ?", "gz_operator").Update("status", "disabled").Error; err != nil {
t.Fatal(err)
}
_, err = svc.Login(ctx, DistributionLoginRequest{
Username: "gz_operator",
Password: "Password123!",
}, "127.0.0.1")
if err != ErrAdminDisabled {
t.Fatalf("expected ErrAdminDisabled, got %v", err)
}
// 7. Test superadmin login (user without site member binding)
hash, _ := bcrypt.GenerateFromPassword([]byte("SuperAdmin123!"), bcrypt.DefaultCost)
superAdmin := model.AdminUser{
Username: "platform_admin",
PasswordHash: string(hash),
Nickname: "平台超级管理员",
Status: "active",
TokenVersion: 1,
}
if err := db.Create(&superAdmin).Error; err != nil {
t.Fatal(err)
}
superLoginResp, err := svc.Login(ctx, DistributionLoginRequest{
Username: "platform_admin",
Password: "SuperAdmin123!",
}, "127.0.0.1")
if err != nil {
t.Fatalf("superadmin login failed: %v", err)
}
if !superLoginResp.Member.IsSuperadmin {
t.Fatalf("expected IsSuperadmin=true for platform superadmin")
}
if superLoginResp.Member.SiteID != nil {
t.Fatalf("expected nil SiteID for superadmin, got %v", superLoginResp.Member.SiteID)
}
// 8. Test Me profile
profile, err := svc.Me(ctx, superAdmin.ID)
if err != nil {
t.Fatalf("failed to get profile: %v", err)
}
if profile.Username != "platform_admin" || !profile.IsSuperadmin {
t.Fatalf("unexpected profile: %#v", profile)
}
}
@@ -186,3 +186,49 @@ type SettlementItemDTO struct {
AmountCent int64 `json:"amount_cent"`
Status string `json:"status"`
}
type DistributionLoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type DistributionLoginResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
Member DistributionCurrentMemberDTO `json:"member"`
}
type DistributionCurrentMemberDTO struct {
AdminID uint64 `json:"admin_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
IsSuperadmin bool `json:"is_superadmin"`
SiteID *uint64 `json:"site_id,omitempty"`
SiteCode string `json:"site_code,omitempty"`
SiteName string `json:"site_name,omitempty"`
Domain string `json:"domain,omitempty"`
MemberType string `json:"member_type,omitempty"`
}
type OnboardSiteRequest struct {
SiteCode string `json:"site_code" binding:"required,max=64"`
SiteName string `json:"site_name" binding:"required,max=128"`
Domain string `json:"domain" binding:"required,max=255"`
DisplayAccountEnabled *bool `json:"display_account_enabled"`
TaxEnabled *bool `json:"tax_enabled"`
TaxRateBPS *int64 `json:"tax_rate_bps"`
AdminUsername string `json:"admin_username" binding:"required,min=3,max=64"`
AdminPassword string `json:"admin_password" binding:"required,min=6,max=64"`
AdminNickname string `json:"admin_nickname"`
MemberType string `json:"member_type" binding:"omitempty,oneof=operator finance customer_service"`
DistributorReceiveRatio float64 `json:"distributor_receive_ratio"`
PlatformSellRatio float64 `json:"platform_sell_ratio"`
ServiceFeeValue float64 `json:"service_fee_value"`
}
type OnboardSiteResponse struct {
Site SiteDTO `json:"site"`
Rule RuleDTO `json:"rule"`
Member DistributionCurrentMemberDTO `json:"member"`
}
@@ -364,6 +364,65 @@ func parseQueryUint(c *gin.Context, key string) int64 {
return int64(id)
}
func (h *Handler) Login(c *gin.Context) {
var req DistributionLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "用户名和密码不能为空")
return
}
resp, err := h.service.Login(c.Request.Context(), req, c.ClientIP())
if err != nil {
writeError(c, err)
return
}
secure := isHTTPSRequest(c)
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(middleware.AdminAccessCookieName, resp.Token, 2*3600, "/api", "", secure, true)
response.OK(c, resp)
}
func (h *Handler) Me(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
profile, err := h.service.Me(c.Request.Context(), adminID)
if err != nil {
writeError(c, err)
return
}
response.OK(c, profile)
}
func (h *Handler) Logout(c *gin.Context) {
secure := isHTTPSRequest(c)
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(middleware.AdminAccessCookieName, "", -1, "/api", "", secure, true)
response.OK(c, gin.H{"logged_out": true})
}
func (h *Handler) OnboardSite(c *gin.Context) {
var req OnboardSiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "开通分销商参数格式不正确")
return
}
resp, err := h.service.OnboardSite(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, resp)
}
func isHTTPSRequest(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
@@ -390,6 +449,14 @@ func writeError(c *gin.Context, err error) {
response.BadRequest(c, "支付商户配置不存在")
case errors.Is(err, ErrOrderSnapshotExists):
response.Error(c, http.StatusConflict, "snapshot_exists", "该订单已存在财务快照,不能重复补数")
case errors.Is(err, ErrDuplicateSiteCodeOrDomain):
response.Error(c, http.StatusConflict, "duplicate_site", "分销站点标识或域名已被占用")
case errors.Is(err, ErrDuplicateUsername):
response.Error(c, http.StatusConflict, "duplicate_username", "管理员用户名已被占用")
case errors.Is(err, ErrInvalidCredential):
response.BadRequest(c, "用户名或密码错误")
case errors.Is(err, ErrAdminDisabled):
response.Error(c, http.StatusForbidden, "admin_disabled", "分销管理员账号已被禁用")
case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled), errors.Is(err, ErrSettlementBatchEmpty), errors.Is(err, ErrSettlementTotalMismatch):
response.BadRequest(c, "分销规则参数不正确")
case errors.Is(err, ErrSiteAccessDenied):
@@ -0,0 +1,191 @@
package distribution
import (
"testing"
"time"
"hfb_sys/backend/internal/model"
)
func TestResolveOrderSourceTx(t *testing.T) {
db := distributionTestDB(t)
// 空 siteCode 正常返回 nil
site, rule, err := ResolveOrderSourceTx(db, "", 1, 1, time.Now())
if err != nil || site != nil || rule != nil {
t.Fatalf("expected nil for empty siteCode, got site=%v, rule=%v, err=%v", site, rule, err)
}
// 站点不存在
_, _, err = ResolveOrderSourceTx(db, "unknown_site", 1, 1, time.Now())
if err != ErrSiteNotFound {
t.Fatalf("expected ErrSiteNotFound, got %v", err)
}
// 创建正常站点
now := time.Now()
activeSite := model.DistributionSite{
SiteCode: "site_test",
SiteName: "测试分销站",
Domain: "test.dist.com",
Status: "active",
DisplayAccountEnabled: true,
}
if err := db.Create(&activeSite).Error; err != nil {
t.Fatal(err)
}
// 账号展示关系不存在
_, _, err = ResolveOrderSourceTx(db, "site_test", 10, 20, now)
if err != ErrAccountDisplayNotFound {
t.Fatalf("expected ErrAccountDisplayNotFound, got %v", err)
}
// 创建展示关系
accountDisplay := model.DistributionSiteAccount{
SiteID: activeSite.ID,
AccountID: 20,
ListingID: 10,
DisplayStatus: "enabled",
}
if err := db.Create(&accountDisplay).Error; err != nil {
t.Fatal(err)
}
// 规则不存在
_, _, err = ResolveOrderSourceTx(db, "site_test", 10, 20, now)
if err != ErrRuleNotFound {
t.Fatalf("expected ErrRuleNotFound, got %v", err)
}
// 创建有效规则
ruleVersion := model.DistributionRuleVersion{
SiteID: &activeSite.ID,
Mode: ModeTwoPartyDistributorReceive,
Version: 1,
Status: "active",
DistributorReceiveRatio: 3.5,
PlatformSellRatio: 4.0,
ServiceFeeValue: 2.5,
ServiceFeeUnit: ServiceFeeUnitFixedAmount,
PaymentFeeBPS: 50,
EffectiveFrom: now.Add(-1 * time.Hour),
}
if err := db.Create(&ruleVersion).Error; err != nil {
t.Fatal(err)
}
// 正常解析
foundSite, foundRule, err := ResolveOrderSourceTx(db, "site_test", 10, 20, now)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if foundSite.ID != activeSite.ID || foundRule.ID != ruleVersion.ID {
t.Fatalf("unexpected result: site=%v, rule=%v", foundSite, foundRule)
}
}
func TestSnapshotPaidOrderTxAndApplyRefundTx(t *testing.T) {
db := distributionTestDB(t)
now := time.Now()
site := model.DistributionSite{
SiteCode: "site_pay",
SiteName: "支付测试站",
Domain: "pay.dist.com",
Status: "active",
DisplayAccountEnabled: true,
TaxEnabled: true,
TaxRateBPS: 100,
}
if err := db.Create(&site).Error; err != nil {
t.Fatal(err)
}
rule := model.DistributionRuleVersion{
SiteID: &site.ID,
Mode: ModeTwoPartyDistributorReceive,
Version: 1,
Status: "active",
DistributorReceiveRatio: 3.5,
PlatformSellRatio: 4.0,
ServiceFeeValue: 2.5,
ServiceFeeUnit: ServiceFeeUnitFixedAmount,
PaymentFeeBPS: 50,
TaxEnabled: true,
TaxRateBPS: 100,
EffectiveFrom: now.Add(-1 * time.Hour),
}
if err := db.Create(&rule).Error; err != nil {
t.Fatal(err)
}
order := model.RentalOrder{
OrderNo: "ORD-DIST-1",
ListingID: 101,
AccountID: 201,
OwnerID: 1,
RenterID: 2,
Status: "renting",
PlatformFeeCent: 300,
DistributionSiteID: &site.ID,
DistributionRuleVersionID: &rule.ID,
}
if err := db.Create(&order).Error; err != nil {
t.Fatal(err)
}
// 1. SnapshotPaidOrderTx
grossPaid := int64(10000)
if err := SnapshotPaidOrderTx(db, order.ID, grossPaid); err != nil {
t.Fatalf("SnapshotPaidOrderTx failed: %v", err)
}
var financial model.DistributionOrderFinancial
if err := db.Where("order_id = ?", order.ID).First(&financial).Error; err != nil {
t.Fatalf("find financial failed: %v", err)
}
if financial.GrossPaidAmountCent != grossPaid || financial.SiteID != site.ID {
t.Fatalf("unexpected financial snapshot: %+v", financial)
}
var parties []model.DistributionOrderParty
if err := db.Where("order_id = ?", order.ID).Find(&parties).Error; err != nil || len(parties) != 2 {
t.Fatalf("unexpected parties count: len=%d, err=%v", len(parties), err)
}
// 幂等写入测试:重复调用不报错且不重复追加 party
if err := SnapshotPaidOrderTx(db, order.ID, grossPaid); err != nil {
t.Fatalf("idempotent SnapshotPaidOrderTx failed: %v", err)
}
var partiesAfter []model.DistributionOrderParty
if err := db.Where("order_id = ?", order.ID).Find(&partiesAfter).Error; err != nil || len(partiesAfter) != 2 {
t.Fatalf("idempotency broken: len=%d", len(partiesAfter))
}
// 2. ApplyRefundTx
// 非法金额
if err := ApplyRefundTx(db, order.ID, -10); err != ErrInvalidAmount {
t.Fatalf("expected ErrInvalidAmount for negative refund, got %v", err)
}
if err := ApplyRefundTx(db, order.ID, grossPaid+100); err != ErrInvalidAmount {
t.Fatalf("expected ErrInvalidAmount for excessive refund, got %v", err)
}
// 合法退款
refundAmount := int64(2000)
if err := ApplyRefundTx(db, order.ID, refundAmount); err != nil {
t.Fatalf("ApplyRefundTx failed: %v", err)
}
var updatedFinancial model.DistributionOrderFinancial
if err := db.Where("order_id = ?", order.ID).First(&updatedFinancial).Error; err != nil {
t.Fatalf("find updated financial failed: %v", err)
}
if updatedFinancial.RefundAmountCent != refundAmount {
t.Fatalf("expected refund amount %d, got %d", refundAmount, updatedFinancial.RefundAmountCent)
}
if updatedFinancial.NetPaidAmountCent != grossPaid-refundAmount {
t.Fatalf("expected net paid %d, got %d", grossPaid-refundAmount, updatedFinancial.NetPaidAmountCent)
}
}
@@ -2,13 +2,17 @@ package distribution
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -25,14 +29,27 @@ var (
ErrBatchAlreadySettled = errors.New("distribution settlement batch already settled")
ErrSettlementBatchEmpty = errors.New("distribution settlement batch empty")
ErrSettlementTotalMismatch = errors.New("distribution settlement total mismatch")
ErrPaymentConfigNotFound = errors.New("distribution payment config not found")
ErrOrderSnapshotExists = errors.New("distribution order snapshot exists")
ErrSiteAccessDenied = errors.New("distribution site access denied")
ErrPaymentConfigNotFound = errors.New("distribution payment config not found")
ErrOrderSnapshotExists = errors.New("distribution order snapshot exists")
ErrSiteAccessDenied = errors.New("distribution site access denied")
ErrInvalidCredential = errors.New("invalid credential")
ErrAdminDisabled = errors.New("admin disabled")
ErrDuplicateSiteCodeOrDomain = errors.New("distribution site code or domain already exists")
ErrDuplicateUsername = errors.New("username already exists")
)
type Repository struct{ db *gorm.DB }
type Repository struct {
db *gorm.DB
redis *redis.Client
}
func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} }
func NewRepository(db *gorm.DB, redis ...*redis.Client) *Repository {
r := &Repository{db: db}
if len(redis) > 0 {
r.redis = redis[0]
}
return r
}
func (r *Repository) ScopeSiteID(ctx context.Context, adminID, requested uint64) (uint64, error) {
if adminID == 0 {
@@ -612,3 +629,256 @@ func (r *Repository) ListSettlementItems(ctx context.Context, adminID, batchID u
}
return items, nil
}
func (r *Repository) LoginMember(ctx context.Context, username, password string) (*model.AdminUser, *DistributionCurrentMemberDTO, error) {
if r.db == nil {
return nil, nil, ErrDependencyUnavailable
}
var admin model.AdminUser
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, ErrInvalidCredential
}
return nil, nil, err
}
if admin.Status != "active" {
return nil, nil, ErrAdminDisabled
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
return nil, nil, ErrInvalidCredential
}
var members []model.DistributionSiteMember
if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", admin.ID).Find(&members).Error; err != nil {
return nil, nil, err
}
dto := DistributionCurrentMemberDTO{
AdminID: admin.ID,
Username: admin.Username,
Nickname: admin.Nickname,
IsSuperadmin: len(members) == 0,
}
if len(members) > 0 {
var site model.DistributionSite
if err := r.db.WithContext(ctx).First(&site, members[0].SiteID).Error; err == nil {
dto.SiteID = &site.ID
dto.SiteCode = site.SiteCode
dto.SiteName = site.SiteName
dto.Domain = site.Domain
dto.MemberType = members[0].MemberType
}
}
now := time.Now()
admin.LastLoginAt = &now
_ = r.db.WithContext(ctx).Model(&admin).Update("last_login_at", now).Error
if r.redis != nil {
if dto.IsSuperadmin {
raw, _ := json.Marshal([]string{"*"})
_ = r.redis.Set(ctx, fmt.Sprintf("admin:perms:%d", admin.ID), string(raw), 2*time.Hour).Err()
} else {
var permCodes []string
if r.db != nil {
_ = r.db.WithContext(ctx).Table("permissions").
Select("DISTINCT permissions.code").
Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id").
Joins("JOIN admin_user_roles aur ON aur.role_id = rp.role_id").
Where("aur.admin_user_id = ?", admin.ID).
Pluck("code", &permCodes).Error
}
if len(permCodes) == 0 {
permCodes = []string{
"distribution:site_view",
"distribution:rule_view",
"distribution:order_view",
"distribution:account_view",
"distribution:account_display",
"distribution:payment_view",
"distribution:payment_manage",
"distribution:settlement_view",
"distribution:settlement_manage",
}
}
raw, _ := json.Marshal(permCodes)
_ = r.redis.Set(ctx, fmt.Sprintf("admin:perms:%d", admin.ID), string(raw), 2*time.Hour).Err()
}
}
return &admin, &dto, nil
}
func (r *Repository) GetMemberProfile(ctx context.Context, adminID uint64) (*DistributionCurrentMemberDTO, error) {
if r.db == nil {
return nil, ErrDependencyUnavailable
}
var admin model.AdminUser
if err := r.db.WithContext(ctx).First(&admin, adminID).Error; err != nil {
return nil, err
}
var members []model.DistributionSiteMember
if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
return nil, err
}
dto := DistributionCurrentMemberDTO{
AdminID: admin.ID,
Username: admin.Username,
Nickname: admin.Nickname,
IsSuperadmin: len(members) == 0,
}
if len(members) > 0 {
var site model.DistributionSite
if err := r.db.WithContext(ctx).First(&site, members[0].SiteID).Error; err == nil {
dto.SiteID = &site.ID
dto.SiteCode = site.SiteCode
dto.SiteName = site.SiteName
dto.Domain = site.Domain
dto.MemberType = members[0].MemberType
}
}
return &dto, nil
}
func (r *Repository) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*OnboardSiteResponse, error) {
if r.db == nil {
return nil, ErrDependencyUnavailable
}
var count int64
if err := r.db.WithContext(ctx).Model(&model.DistributionSite{}).
Where("site_code = ? OR domain = ?", req.SiteCode, req.Domain).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, ErrDuplicateSiteCodeOrDomain
}
if err := r.db.WithContext(ctx).Model(&model.AdminUser{}).
Where("username = ?", req.AdminUsername).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, ErrDuplicateUsername
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.AdminPassword), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
var resp OnboardSiteResponse
err = r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
displayEnabled := true
if req.DisplayAccountEnabled != nil {
displayEnabled = *req.DisplayAccountEnabled
}
taxEnabled := false
if req.TaxEnabled != nil {
taxEnabled = *req.TaxEnabled
}
taxRate := int64(100)
if req.TaxRateBPS != nil {
taxRate = *req.TaxRateBPS
}
site := model.DistributionSite{
SiteCode: req.SiteCode,
SiteName: req.SiteName,
Domain: req.Domain,
Status: "active",
DisplayAccountEnabled: displayEnabled,
TaxEnabled: taxEnabled,
TaxRateBPS: taxRate,
}
if err := tx.Create(&site).Error; err != nil {
return err
}
nickname := req.AdminNickname
if nickname == "" {
nickname = req.SiteName + "管理员"
}
adminUser := model.AdminUser{
Username: req.AdminUsername,
PasswordHash: string(hash),
Nickname: nickname,
Status: "active",
TokenVersion: 1,
PasswordMustChange: false,
}
if err := tx.Create(&adminUser).Error; err != nil {
return err
}
var roleID uint64
if err := tx.Table("roles").Where("code = ?", "ops").Pluck("id", &roleID).Error; err == nil && roleID > 0 {
_ = tx.Exec("INSERT IGNORE INTO admin_user_roles (admin_user_id, role_id) VALUES (?, ?)", adminUser.ID, roleID).Error
}
memberType := req.MemberType
if memberType == "" {
memberType = "operator"
}
member := model.DistributionSiteMember{
SiteID: site.ID,
AdminUserID: adminUser.ID,
MemberType: memberType,
Status: "active",
DataScope: "site",
}
if err := tx.Create(&member).Error; err != nil {
return err
}
distReceive := req.DistributorReceiveRatio
if distReceive == 0 {
distReceive = 3.5
}
platSell := req.PlatformSellRatio
if platSell == 0 {
platSell = 4.0
}
serviceFee := req.ServiceFeeValue
if serviceFee == 0 {
serviceFee = 2.5
}
rule := model.DistributionRuleVersion{
SiteID: &site.ID,
Mode: ModeTwoPartyDistributorReceive,
Version: 1,
Status: "active",
DistributorReceiveRatio: distReceive,
PlatformSellRatio: platSell,
ServiceFeeValue: serviceFee,
ServiceFeeUnit: ServiceFeeUnitFixedAmount,
PaymentFeeBPS: 50,
TaxEnabled: taxEnabled,
TaxRateBPS: taxRate,
EffectiveFrom: time.Now(),
}
if err := tx.Create(&rule).Error; err != nil {
return err
}
resp = OnboardSiteResponse{
Site: siteDTO(site),
Rule: ruleDTO(rule),
Member: DistributionCurrentMemberDTO{
AdminID: adminUser.ID,
Username: adminUser.Username,
Nickname: adminUser.Nickname,
IsSuperadmin: false,
SiteID: &site.ID,
SiteCode: site.SiteCode,
SiteName: site.SiteName,
Domain: site.Domain,
MemberType: member.MemberType,
},
}
return nil
})
if err != nil {
return nil, err
}
return &resp, nil
}
@@ -16,7 +16,7 @@ func distributionTestDB(t *testing.T) *gorm.DB {
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.ChatConversation{}, &model.RentalOrder{}, &model.AuditLog{}, &model.PaymentMerchantConfig{}, &model.DistributionSite{}, &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
if err := db.AutoMigrate(&model.AdminUser{}, &model.Role{}, &model.ChatConversation{}, &model.RentalOrder{}, &model.AuditLog{}, &model.PaymentMerchantConfig{}, &model.DistributionSite{}, &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, &model.DistributionOrderParty{}, &model.DistributionSiteAccount{}, &model.DistributionSitePaymentChannel{}, &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}); err != nil {
t.Fatal(err)
}
return db
@@ -5,13 +5,23 @@ import (
"errors"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/auth"
)
var ErrDependencyUnavailable = errors.New("distribution dependency unavailable")
type Service struct{ repo *Repository }
type Service struct {
repo *Repository
jwt *auth.JWTManager
}
func NewService(repo *Repository) *Service { return &Service{repo: repo} }
func NewService(repo *Repository, jwt ...*auth.JWTManager) *Service {
s := &Service{repo: repo}
if len(jwt) > 0 {
s.jwt = jwt[0]
}
return s
}
func (s *Service) available() error {
if s == nil || s.repo == nil {
@@ -171,3 +181,40 @@ func (s *Service) CreateFinancialSnapshot(ctx context.Context, adminID, orderID
}
return s.repo.CreateFinancialSnapshot(ctx, adminID, orderID, req)
}
func (s *Service) Login(ctx context.Context, req DistributionLoginRequest, clientIP string) (*DistributionLoginResponse, error) {
if err := s.available(); err != nil {
return nil, err
}
if s.jwt == nil {
return nil, ErrDependencyUnavailable
}
admin, member, err := s.repo.LoginMember(ctx, req.Username, req.Password)
if err != nil {
return nil, err
}
pair, err := s.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
if err != nil {
return nil, err
}
return &DistributionLoginResponse{
Token: pair.AccessToken,
RefreshToken: pair.RefreshToken,
Member: *member,
}, nil
}
func (s *Service) Me(ctx context.Context, adminID uint64) (*DistributionCurrentMemberDTO, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.GetMemberProfile(ctx, adminID)
}
func (s *Service) OnboardSite(ctx context.Context, req OnboardSiteRequest) (*OnboardSiteResponse, error) {
if err := s.available(); err != nil {
return nil, err
}
return s.repo.OnboardSite(ctx, req)
}
+16 -2
View File
@@ -312,9 +312,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
var distributionRepo *distribution.Repository
if deps.DB != nil {
distributionRepo = distribution.NewRepository(deps.DB)
distributionRepo = distribution.NewRepository(deps.DB, deps.Redis)
}
distributionService := distribution.NewService(distributionRepo)
distributionService := distribution.NewService(distributionRepo, jwtManager)
distributionHandler := distribution.NewHandler(distributionService)
backupMonitorHandler := backupmonitor.NewHandler(cfg.BackupStatusFile)
var adminRoleRepo *adminrole.Repository
@@ -593,16 +593,30 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminAuthRoutes.POST("/refresh", adminAuthHandler.Refresh)
}
distributionAuthRoutes := api.Group("/distribution/auth")
{
distributionAuthRoutes.POST("/login", distributionHandler.Login)
}
distributionProtectedRoutes := api.Group("/distribution", requireAdmin)
{
distributionProtectedRoutes.GET("/auth/me", distributionHandler.Me)
distributionProtectedRoutes.POST("/auth/logout", distributionHandler.Logout)
}
adminRoutes := api.Group("/admin", requireAdmin, middleware.RequireAdminPasswordChanged())
{
adminRoutes.GET("/me", adminAuthHandler.Me)
adminRoutes.PUT("/me/support-status", adminAuthHandler.UpdateSupportStatus)
adminRoutes.POST("/auth/logout", adminAuthHandler.Logout)
adminRoutes.GET("/distribution/auth/me", distributionHandler.Me)
adminRoutes.POST("/distribution/auth/logout", distributionHandler.Logout)
adminRoutes.POST("/files/upload", fileHandler.Upload)
adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary)
adminRoutes.GET("/distribution/sites", requirePerm("distribution:site_view"), distributionHandler.ListSites)
adminRoutes.GET("/distribution/sites/:id", requirePerm("distribution:site_view"), distributionHandler.GetSite)
adminRoutes.POST("/distribution/sites", requirePerm("distribution:site_manage"), distributionHandler.CreateSite)
adminRoutes.POST("/distribution/sites/onboard", requirePerm("distribution:site_manage"), distributionHandler.OnboardSite)
adminRoutes.PUT("/distribution/sites/:id", requirePerm("distribution:site_manage"), distributionHandler.UpdateSite)
adminRoutes.GET("/distribution/rules", requirePerm("distribution:rule_view"), distributionHandler.ListRules)
adminRoutes.POST("/distribution/rules", requirePerm("distribution:rule_manage"), distributionHandler.CreateRule)