feat(distribution): 独立分销后台账号体系、多租户隔离与主站后台分销管理GUI
- 独立账号鉴权:分销后台支持独立登录页面、Bearer+Cookie 双模鉴权与租户资料接口 - 多租户隔离:分销商成员强制锁定自身站点数据范围,防止横向越权;平台超管享有全局视界 - 一键开通分销商:平台端支持一键录入站点、管理员账号、分配角色并原子化初始化佣金规则 - 主后台分销分组:在主站后台增加「分销管理」导航分组,提供站点管理、规则配置、订单监控与结算管理 GUI - 门禁与联调:补充完整单测,全量代码门禁 check.sh 100% 通过,dev.sh 支持前后端与分销后台一键同启
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user