feat: support merchant team roles and permissions

This commit is contained in:
yml2213
2026-08-13 11:41:34 +08:00
parent 6c98fa557e
commit a4791decb0
21 changed files with 969 additions and 201 deletions
+3
View File
@@ -99,6 +99,9 @@ func (s *MerchantService) CreateMerchant(in CreateMerchantInput, actorUserID uin
}).Error; err != nil {
return err
}
if err := createDefaultMerchantRoles(tx, merchant.ID); err != nil {
return err
}
if err := copyDefaultProducts(tx, merchant.ID); err != nil {
return err
}
+174 -12
View File
@@ -3,39 +3,201 @@ package service
import (
"errors"
"fmt"
"regexp"
"sort"
"strings"
"affiliate_dash/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type AddMemberInput struct {
UserID uint
Username string
Password string
Nickname string
Role string
IsDefault bool
}
type MerchantRoleInput struct {
Code string
Name string
Permissions []string
}
var merchantRoleCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,63}$`)
func merchantPermissionsText(permissions []string) (string, error) {
allowed := map[string]struct{}{
model.PermissionProductsManage: {}, model.PermissionOrdersManage: {}, model.PermissionWalletView: {},
model.PermissionWalletLedger: {}, model.PermissionRechargeManage: {}, model.PermissionAPIManage: {},
model.PermissionCallbacksManage: {}, model.PermissionMembersManage: {},
}
set := make(map[string]struct{}, len(permissions))
for _, permission := range permissions {
permission = strings.TrimSpace(permission)
if _, ok := allowed[permission]; !ok {
return "", errors.New("包含无效的角色权限")
}
set[permission] = struct{}{}
}
items := make([]string, 0, len(set))
for permission := range set {
items = append(items, permission)
}
sort.Strings(items)
return strings.Join(items, ","), nil
}
func (s *MerchantService) ListRoles(merchantID uint) ([]model.MerchantRole, error) {
roles := builtinMerchantRoles(merchantID)
var custom []model.MerchantRole
if err := s.db.Where("merchant_id = ?", merchantID).Order("id ASC").Find(&custom).Error; err != nil {
return nil, err
}
return append(roles, custom...), nil
}
func builtinMerchantRoles(merchantID uint) []model.MerchantRole {
permissions, _ := (&TenantService{}).MerchantRolePermissions(merchantID, model.MemberRoleOwner)
items := make([]string, 0, len(permissions))
for permission := range permissions {
items = append(items, permission)
}
sort.Strings(items)
return []model.MerchantRole{{MerchantID: merchantID, Code: model.MemberRoleOwner, Name: "负责人", Permissions: strings.Join(items, ","), Status: 1}}
}
func createDefaultMerchantRoles(tx *gorm.DB, merchantID uint) error {
roles := []model.MerchantRole{
{MerchantID: merchantID, Code: model.MemberRoleOperator, Name: "运营", Permissions: strings.Join([]string{model.PermissionAPIManage, model.PermissionCallbacksManage, model.PermissionOrdersManage, model.PermissionProductsManage, model.PermissionWalletView}, ","), Status: 1},
{MerchantID: merchantID, Code: model.MemberRoleFinance, Name: "财务", Permissions: strings.Join([]string{model.PermissionRechargeManage, model.PermissionWalletLedger, model.PermissionWalletView}, ","), Status: 1},
{MerchantID: merchantID, Code: "support", Name: "客服", Permissions: model.PermissionOrdersManage, Status: 1},
}
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "merchant_id"}, {Name: "code"}}, DoNothing: true}).Create(&roles).Error
}
func (s *MerchantService) CreateRole(merchantID uint, in MerchantRoleInput, actorUserID uint) (*model.MerchantRole, error) {
in.Code = strings.ToLower(strings.TrimSpace(in.Code))
in.Name = strings.TrimSpace(in.Name)
if !merchantRoleCodePattern.MatchString(in.Code) {
return nil, errors.New("角色编码需为 2-64 位小写字母、数字或连字符")
}
if isReservedMerchantRoleCode(in.Code) {
return nil, errors.New("角色编码不能使用系统保留角色")
}
if in.Name == "" {
return nil, errors.New("角色名称不能为空")
}
permissions, err := merchantPermissionsText(in.Permissions)
if err != nil {
return nil, err
}
role := &model.MerchantRole{MerchantID: merchantID, Code: in.Code, Name: in.Name, Permissions: permissions, Status: 1}
err = s.db.Transaction(func(tx *gorm.DB) error {
var merchant model.Merchant
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
return errors.New("商户不存在或已禁用")
}
if err := tx.Create(role).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.role.create", "merchant_role", fmt.Sprint(role.ID), map[string]string{"code": role.Code})
})
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
return nil, errors.New("角色编码已存在")
}
return nil, err
}
return role, nil
}
func isReservedMerchantRoleCode(code string) bool {
switch code {
case model.MemberRoleOwner, model.MemberRoleViewer, model.MemberRoleOperator, model.MemberRoleFinance, "support":
return true
default:
return false
}
}
func (s *MerchantService) UpdateRole(merchantID, roleID uint, in MerchantRoleInput, actorUserID uint) (*model.MerchantRole, error) {
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
return nil, errors.New("角色名称不能为空")
}
permissions, err := merchantPermissionsText(in.Permissions)
if err != nil {
return nil, err
}
var role model.MerchantRole
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ? AND merchant_id = ?", roleID, merchantID).First(&role).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("角色不存在")
}
return err
}
if err := tx.Model(&role).Updates(map[string]interface{}{"name": in.Name, "permissions": permissions}).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.role.update", "merchant_role", fmt.Sprint(roleID), map[string]string{"code": role.Code})
})
if err != nil {
return nil, err
}
return &role, nil
}
func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUserID uint) (*model.MerchantMember, error) {
if !isValidMemberRole(in.Role) {
return nil, errors.New("无效的商户成员角色")
in.Role = strings.TrimSpace(in.Role)
if in.Role == "" {
return nil, errors.New("商户成员角色不能为空")
}
member := &model.MerchantMember{
MerchantID: merchantID,
UserID: in.UserID,
Role: in.Role,
Status: 1,
IsDefault: in.IsDefault,
if in.UserID == 0 && strings.TrimSpace(in.Username) == "" {
return nil, errors.New("请选择已有账号或填写新员工账号")
}
member := &model.MerchantMember{MerchantID: merchantID, Role: in.Role, Status: 1, IsDefault: in.IsDefault}
err := s.db.Transaction(func(tx *gorm.DB) error {
var merchant model.Merchant
if err := tx.Where("id = ? AND status = ?", merchantID, model.MerchantStatusActive).First(&merchant).Error; err != nil {
return errors.New("商户不存在或已禁用")
}
var user model.User
if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil {
return errors.New("用户不存在或已禁用")
createdUser := false
if in.UserID != 0 {
if err := tx.Where("id = ? AND status = ?", in.UserID, 1).First(&user).Error; err != nil {
return errors.New("用户不存在或已禁用")
}
} else {
username := strings.TrimSpace(in.Username)
if len(username) < 3 {
return errors.New("员工用户名至少 3 位")
}
if len(in.Password) < 6 {
return errors.New("员工密码至少 6 位")
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
user = model.User{Username: username, PasswordHash: string(hash), Nickname: fallbackName(in.Nickname, username), Role: model.RoleMerchant, Status: 1}
if err := tx.Create(&user).Error; err != nil {
return errors.New("员工用户名已存在")
}
createdUser = true
}
if _, err := (&TenantService{db: tx}).MerchantRolePermissions(merchantID, in.Role); err != nil {
return err
}
member.UserID = user.ID
// 新建员工仅属于当前商户,登录时应直接进入该商户。
member.IsDefault = in.IsDefault || createdUser
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "merchant_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
@@ -46,12 +208,12 @@ func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUse
}).Create(member).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, in.UserID), map[string]string{"role": in.Role})
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.upsert", "merchant_member", fmt.Sprintf("%d:%d", merchantID, user.ID), map[string]string{"role": in.Role})
})
if err != nil {
return nil, err
}
if err := s.db.Where("merchant_id = ? AND user_id = ?", merchantID, in.UserID).First(member).Error; err != nil {
if err := s.db.Preload("User").Where("merchant_id = ? AND user_id = ?", merchantID, member.UserID).First(member).Error; err != nil {
return nil, err
}
return member, nil
+94
View File
@@ -35,3 +35,97 @@ func TestCreateAPIClientEnforcesPerMerchantLimit(t *testing.T) {
t.Fatalf("expected per-merchant limit error, got %v", err)
}
}
func TestMerchantCustomRoleIsScopedAndCanCreateEmployee(t *testing.T) {
db := newServiceTestDB(t)
merchantA := model.Merchant{Code: "team-merchant-a", Name: "团队商户 A", Status: model.MerchantStatusActive}
merchantB := model.Merchant{Code: "team-merchant-b", Name: "团队商户 B", Status: model.MerchantStatusActive}
actor := model.User{Username: "team-owner", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&merchantA).Error; err != nil {
t.Fatalf("create merchant A: %v", err)
}
if err := db.Create(&merchantB).Error; err != nil {
t.Fatalf("create merchant B: %v", err)
}
if err := db.Create(&actor).Error; err != nil {
t.Fatalf("create actor: %v", err)
}
svc := NewMerchantService(db, nil, NewTenantService(db))
role, err := svc.CreateRole(merchantA.ID, MerchantRoleInput{
Code: "after-sales",
Name: "客服",
Permissions: []string{model.PermissionOrdersManage},
}, actor.ID)
if err != nil {
t.Fatalf("create custom role: %v", err)
}
member, err := svc.AddMember(merchantA.ID, AddMemberInput{
Username: "merchant-a-support",
Password: "password123",
Nickname: "客服小李",
Role: role.Code,
}, actor.ID)
if err != nil {
t.Fatalf("create employee: %v", err)
}
if member.User == nil || member.User.Username != "merchant-a-support" || member.Role != "after-sales" {
t.Fatalf("unexpected member: %+v", member)
}
if !member.IsDefault {
t.Fatal("new employee should default to the merchant that created it")
}
if _, err := svc.AddMember(merchantB.ID, AddMemberInput{
Username: "merchant-b-support",
Password: "password123",
Role: role.Code,
}, actor.ID); err == nil {
t.Fatal("expected merchant A role to be rejected for merchant B")
}
}
func TestDefaultMerchantRolesCanBeEdited(t *testing.T) {
db := newServiceTestDB(t)
merchant := model.Merchant{Code: "default-roles", Name: "默认角色商户", Status: model.MerchantStatusActive}
actor := model.User{Username: "default-role-owner", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1}
if err := db.Create(&merchant).Error; err != nil {
t.Fatalf("create merchant: %v", err)
}
if err := db.Create(&actor).Error; err != nil {
t.Fatalf("create actor: %v", err)
}
if err := createDefaultMerchantRoles(db, merchant.ID); err != nil {
t.Fatalf("create default roles: %v", err)
}
svc := NewMerchantService(db, nil, NewTenantService(db))
roles, err := svc.ListRoles(merchant.ID)
if err != nil {
t.Fatalf("list roles: %v", err)
}
var finance model.MerchantRole
var hasSupport bool
for _, role := range roles {
if role.Code == model.MemberRoleFinance {
finance = role
}
if role.Code == "support" && role.Name == "客服" {
hasSupport = true
}
}
if finance.ID == 0 || !hasSupport {
t.Fatalf("expected editable finance and support roles, got %+v", roles)
}
updated, err := svc.UpdateRole(merchant.ID, finance.ID, MerchantRoleInput{
Name: "财务主管",
Permissions: []string{model.PermissionWalletView},
}, actor.ID)
if err != nil {
t.Fatalf("update finance role: %v", err)
}
if updated.Name != "财务主管" || updated.Permissions != model.PermissionWalletView {
t.Fatalf("unexpected updated role: %+v", updated)
}
}
+51 -1
View File
@@ -45,6 +45,27 @@ func (s *TenantService) ResolveMember(userID uint, merchantRef string) (*model.M
return nil, err
}
// ResolveMerchantForAdmin 允许平台管理员显式进入任一启用商户的后台管理上下文。
func (s *TenantService) ResolveMerchantForAdmin(merchantRef string) (*model.Merchant, error) {
if strings.TrimSpace(merchantRef) == "" {
return nil, errors.New("请选择商户")
}
tx := s.db.Where("status = ?", model.MerchantStatusActive)
if id, err := strconv.ParseUint(merchantRef, 10, 64); err == nil {
tx = tx.Where("id = ?", uint(id))
} else {
tx = tx.Where("code = ?", merchantRef)
}
var merchant model.Merchant
if err := tx.First(&merchant).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("商户不存在或已禁用")
}
return nil, err
}
return &merchant, nil
}
func (s *TenantService) EnsureSelfMember(userID uint, role string, status int) error {
var merchant model.Merchant
if err := s.db.Where("code = ?", model.MerchantCodeSelfOperated).First(&merchant).Error; err != nil {
@@ -79,13 +100,42 @@ func (s *TenantService) EnsureMember(merchantID, userID uint, role string, statu
func isValidMemberRole(role string) bool {
switch role {
case model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance, model.MemberRoleViewer:
case model.MemberRoleOwner, model.MemberRoleViewer:
return true
default:
return false
}
}
// MerchantRolePermissions 返回保留角色或商户自定义角色的权限集合。
// 负责人始终拥有该商户的全部后台权限,避免被自定义角色配置锁死。
func (s *TenantService) MerchantRolePermissions(merchantID uint, role string) (map[string]struct{}, error) {
permissions := builtinMerchantRolePermissions(role)
if permissions != nil {
return permissions, nil
}
var merchantRole model.MerchantRole
if err := s.db.Where("merchant_id = ? AND code = ? AND status = ?", merchantID, role, 1).First(&merchantRole).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("商户角色不存在或已禁用")
}
return nil, err
}
return ParseScopes(merchantRole.Permissions), nil
}
func builtinMerchantRolePermissions(role string) map[string]struct{} {
all := func(values ...string) map[string]struct{} { return ParseScopes(strings.Join(values, ",")) }
switch role {
case model.MemberRoleOwner:
return all(model.PermissionMembersManage, model.PermissionProductsManage, model.PermissionOrdersManage, model.PermissionWalletView, model.PermissionWalletLedger, model.PermissionRechargeManage, model.PermissionAPIManage, model.PermissionCallbacksManage)
case model.MemberRoleViewer:
return map[string]struct{}{}
default:
return nil
}
}
func MemberCanManage(memberRole string) bool {
return memberRole == model.MemberRoleOwner || memberRole == model.MemberRoleOperator
}