Files
affiliate_dash/backend/internal/service/merchant_member.go
T

448 lines
16 KiB
Go

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 UpdateMemberInput struct {
Role string
Status int
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) {
in.Role = strings.TrimSpace(in.Role)
if in.Role == "" {
return nil, errors.New("商户成员角色不能为空")
}
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
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{}{
"role": in.Role,
"status": 1,
"is_default": in.IsDefault,
}),
}).Create(member).Error; err != nil {
return err
}
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.Preload("User").Where("merchant_id = ? AND user_id = ?", merchantID, member.UserID).First(member).Error; err != nil {
return nil, err
}
return member, nil
}
func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, error) {
var members []model.MerchantMember
err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error
return members, err
}
// UpdateMember updates a member's relationship with one merchant without changing the login account.
func (s *MerchantService) UpdateMember(merchantID, memberID uint, in UpdateMemberInput, actorUserID uint) (*model.MerchantMember, error) {
in.Role = strings.TrimSpace(in.Role)
if in.Role == "" {
return nil, errors.New("商户成员角色不能为空")
}
if in.Status != 0 && in.Status != 1 {
return nil, errors.New("成员状态无效")
}
var member model.MerchantMember
err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ? AND merchant_id = ?", memberID, merchantID).First(&member).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("成员不存在")
}
return err
}
if member.Role == model.MemberRoleOwner {
return errors.New("负责人不能在成员管理中修改")
}
if member.UserID == actorUserID {
return errors.New("不能修改当前登录账号")
}
if _, err := (&TenantService{db: tx}).MerchantRolePermissions(merchantID, in.Role); err != nil {
return err
}
updates := map[string]interface{}{"role": in.Role, "status": in.Status, "is_default": in.IsDefault}
if err := tx.Model(&member).Updates(updates).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.update", "merchant_member", fmt.Sprint(memberID), map[string]string{"role": in.Role, "status": fmt.Sprint(in.Status)})
})
if err != nil {
return nil, err
}
if err := s.db.Preload("User").First(&member, member.ID).Error; err != nil {
return nil, err
}
return &member, nil
}
// RemoveMember removes only the selected merchant relationship and retains the account for audit and other merchants.
func (s *MerchantService) RemoveMember(merchantID, memberID, actorUserID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var member model.MerchantMember
if err := tx.Where("id = ? AND merchant_id = ?", memberID, merchantID).First(&member).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("成员不存在")
}
return err
}
if member.Role == model.MemberRoleOwner {
return errors.New("负责人不能移除")
}
if member.UserID == actorUserID {
return errors.New("不能移除当前登录账号")
}
if err := tx.Delete(&member).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.remove", "merchant_member", fmt.Sprint(memberID), nil)
})
}
// MerchantOwnerAccount 商户负责人登录账号(平台管理员视角)。
type MerchantOwnerAccount struct {
UserID uint `json:"user_id"`
MemberID uint `json:"member_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Status int `json:"status"`
IsDefault bool `json:"is_default"`
}
// ListOwnerAccounts 返回指定商户的全部负责人登录账号(通常一个)。
func (s *MerchantService) ListOwnerAccounts(merchantID uint) ([]MerchantOwnerAccount, error) {
if merchantID == 0 {
return nil, errors.New("无效的商户")
}
var members []model.MerchantMember
if err := s.db.Preload("User").
Where("merchant_id = ? AND role = ?", merchantID, model.MemberRoleOwner).
Order("id ASC").
Find(&members).Error; err != nil {
return nil, err
}
out := make([]MerchantOwnerAccount, 0, len(members))
for _, member := range members {
item := MerchantOwnerAccount{
UserID: member.UserID,
MemberID: member.ID,
Status: member.Status,
IsDefault: member.IsDefault,
}
if member.User != nil {
item.Username = member.User.Username
item.Nickname = member.User.Nickname
item.Status = member.User.Status
}
out = append(out, item)
}
return out, nil
}
// UpdateMerchantOwnerAccountInput 平台管理员维护商户负责人登录账号的输入。
type UpdateMerchantOwnerAccountInput struct {
UserID uint
Username string
Nickname string
Password string
Status *int
}
// UpdateMerchantOwnerAccount 平台管理员修改商户负责人登录账号:
// 改用户名 / 昵称 / 重置密码 / 启用停用。负责人账号受保护:
// 用户名全局唯一,至少保留一个启用的负责人账号,防止商户被锁死。
func (s *MerchantService) UpdateMerchantOwnerAccount(merchantID uint, in UpdateMerchantOwnerAccountInput, actorUserID uint) (*MerchantOwnerAccount, error) {
if merchantID == 0 || in.UserID == 0 {
return nil, errors.New("无效的商户或账号")
}
in.Username = strings.TrimSpace(in.Username)
in.Nickname = strings.TrimSpace(in.Nickname)
if len(in.Username) < 3 || len(in.Username) > 64 {
return nil, errors.New("登录用户名需为 3-64 位")
}
if in.Password != "" && len(in.Password) < 6 {
return nil, errors.New("密码至少 6 位")
}
if in.Status != nil && *in.Status != 0 && *in.Status != 1 {
return nil, errors.New("账号状态无效")
}
var out MerchantOwnerAccount
err := s.db.Transaction(func(tx *gorm.DB) error {
var member model.MerchantMember
if err := tx.Preload("User").
Where("merchant_id = ? AND user_id = ? AND role = ?", merchantID, in.UserID, model.MemberRoleOwner).
First(&member).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("该账号不是该商户的负责人账号")
}
return err
}
if member.User == nil {
return errors.New("负责人账号不存在")
}
user := member.User
if in.Username != user.Username {
var count int64
if err := tx.Model(&model.User{}).Where("username = ? AND id <> ?", in.Username, user.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("用户名已存在")
}
}
// 防锁死:停用最后一个启用的负责人账号会被拒绝。
if in.Status != nil && *in.Status == 0 && user.Status == 1 {
var enabledOwners int64
if err := tx.Model(&model.MerchantMember{}).
Joins("JOIN users ON users.id = merchant_members.user_id AND users.deleted_at IS NULL").
Where("merchant_members.merchant_id = ? AND merchant_members.role = ? AND users.status = 1",
merchantID, model.MemberRoleOwner).
Count(&enabledOwners).Error; err != nil {
return err
}
if enabledOwners <= 1 {
return errors.New("至少保留一个启用的商户负责人账号")
}
}
updates := map[string]interface{}{"username": in.Username, "nickname": in.Nickname}
if in.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
updates["password_hash"] = string(hash)
}
if in.Status != nil {
updates["status"] = *in.Status
}
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
return err
}
metadata := map[string]interface{}{
"username": in.Username,
"nickname": in.Nickname,
}
if in.Password != "" {
metadata["password_reset"] = true
}
if in.Status != nil {
metadata["status"] = *in.Status
}
if err := writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.account.update", "user", fmt.Sprint(user.ID), metadata); err != nil {
return err
}
out = MerchantOwnerAccount{
UserID: user.ID,
MemberID: member.ID,
Username: in.Username,
Nickname: in.Nickname,
Status: user.Status,
IsDefault: member.IsDefault,
}
if in.Status != nil {
out.Status = *in.Status
}
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}