Compare commits

...

2 Commits

Author SHA1 Message Date
3594097d47 各种配置项修复,优化到后台管理端配置
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m59s
2026-03-04 20:03:03 -08:00
2215df8c0b 各种配置项修复,优化到后台管理端配置 2026-03-04 19:42:21 -08:00
8 changed files with 371 additions and 24 deletions

View File

@ -0,0 +1,4 @@
DROP INDEX `idx_user_deleted_at` ON `user_auth_methods`;
ALTER TABLE `user_auth_methods`
DROP COLUMN `deleted_at`;

View File

@ -0,0 +1,4 @@
ALTER TABLE `user_auth_methods`
ADD COLUMN `deleted_at` DATETIME(3) DEFAULT NULL COMMENT 'Deletion Time';
CREATE INDEX `idx_user_deleted_at` ON `user_auth_methods` (`user_id`, `deleted_at`);

View File

@ -40,6 +40,25 @@ func (h *familyBindingHelper) getUserEmailMethod(userId int64) (*user.AuthMethod
return method, nil
}
func validateMemberJoinConflict(ownerFamilyId int64, memberRecord *user.UserFamilyMember) error {
if memberRecord == nil {
return nil
}
if ownerFamilyId != 0 && memberRecord.FamilyId == ownerFamilyId {
if memberRecord.Status == user.FamilyMemberActive {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "user already in this family")
}
return nil
}
if memberRecord.Status == user.FamilyMemberActive {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "user already belongs to another family")
}
return nil
}
func (h *familyBindingHelper) validateJoinFamily(ownerUserId, memberUserId int64) error {
if ownerUserId == memberUserId {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "user already bound to this family")
@ -49,13 +68,14 @@ func (h *familyBindingHelper) validateJoinFamily(ownerUserId, memberUserId int64
err := h.svcCtx.DB.WithContext(h.ctx).
Unscoped().
Model(&user.UserFamily{}).
Where("owner_user_id = ? AND status = ?", ownerUserId, user.FamilyStatusActive).
Where("owner_user_id = ?", ownerUserId).
First(&ownerFamily).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query owner family failed")
}
var memberRecord user.UserFamilyMember
memberExists := false
err = h.svcCtx.DB.WithContext(h.ctx).
Unscoped().
Model(&user.UserFamilyMember{}).
@ -65,10 +85,12 @@ func (h *familyBindingHelper) validateJoinFamily(ownerUserId, memberUserId int64
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query member family relation failed")
}
if err == nil {
if ownerFamily.Id != 0 && memberRecord.FamilyId == ownerFamily.Id {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "user already in this family")
memberExists = true
}
if memberExists {
if err = validateMemberJoinConflict(ownerFamily.Id, &memberRecord); err != nil {
return err
}
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "user already belongs to another family")
}
if ownerFamily.Id == 0 {
@ -116,12 +138,8 @@ func (h *familyBindingHelper) joinFamily(ownerUserId, memberUserId int64, source
memberExists := err == nil
if memberExists {
if memberRecord.FamilyId == ownerFamily.Id {
if memberRecord.Status == user.FamilyMemberActive {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyAlreadyBound), "user already in this family")
}
} else {
return errors.Wrapf(xerr.NewErrCode(xerr.FamilyCrossBindForbidden), "user already belongs to another family")
if err = validateMemberJoinConflict(ownerFamily.Id, &memberRecord); err != nil {
return err
}
}
@ -151,12 +169,16 @@ func (h *familyBindingHelper) joinFamily(ownerUserId, memberUserId int64, source
return nil
}
if memberRecord.FamilyId != ownerFamily.Id {
memberRecord.FamilyId = ownerFamily.Id
}
memberRecord.Status = user.FamilyMemberActive
memberRecord.Role = user.FamilyRoleMember
memberRecord.JoinSource = source
memberRecord.JoinedAt = now
memberRecord.LeftAt = nil
if err = tx.Save(&memberRecord).Error; err != nil {
memberRecord.DeletedAt = gorm.DeletedAt{}
if err = tx.Unscoped().Save(&memberRecord).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update family member failed")
}
return nil

View File

@ -0,0 +1,107 @@
package user
import (
stderrors "errors"
"testing"
modelUser "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/xerr"
pkgerrors "github.com/pkg/errors"
"github.com/stretchr/testify/require"
)
func extractFamilyJoinCode(err error) uint32 {
if err == nil {
return 0
}
var codeErr *xerr.CodeError
if stderrors.As(pkgerrors.Cause(err), &codeErr) {
return codeErr.GetErrCode()
}
return 0
}
func TestValidateMemberJoinConflict(t *testing.T) {
ownerFamilyID := int64(11)
testCases := []struct {
name string
ownerFamily int64
memberRecord *modelUser.UserFamilyMember
wantCode uint32
}{
{
name: "no member record",
ownerFamily: ownerFamilyID,
wantCode: 0,
},
{
name: "same family active member",
ownerFamily: ownerFamilyID,
memberRecord: &modelUser.UserFamilyMember{
FamilyId: ownerFamilyID,
Status: modelUser.FamilyMemberActive,
},
wantCode: xerr.FamilyAlreadyBound,
},
{
name: "same family left member",
ownerFamily: ownerFamilyID,
memberRecord: &modelUser.UserFamilyMember{
FamilyId: ownerFamilyID,
Status: modelUser.FamilyMemberLeft,
},
wantCode: 0,
},
{
name: "same family removed member",
ownerFamily: ownerFamilyID,
memberRecord: &modelUser.UserFamilyMember{
FamilyId: ownerFamilyID,
Status: modelUser.FamilyMemberRemoved,
},
wantCode: 0,
},
{
name: "cross family active member",
ownerFamily: ownerFamilyID,
memberRecord: &modelUser.UserFamilyMember{
FamilyId: ownerFamilyID + 1,
Status: modelUser.FamilyMemberActive,
},
wantCode: xerr.FamilyCrossBindForbidden,
},
{
name: "cross family left member",
ownerFamily: ownerFamilyID,
memberRecord: &modelUser.UserFamilyMember{
FamilyId: ownerFamilyID + 1,
Status: modelUser.FamilyMemberLeft,
},
wantCode: 0,
},
{
name: "cross family removed member",
ownerFamily: ownerFamilyID,
memberRecord: &modelUser.UserFamilyMember{
FamilyId: ownerFamilyID + 1,
Status: modelUser.FamilyMemberRemoved,
},
wantCode: 0,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := validateMemberJoinConflict(testCase.ownerFamily, testCase.memberRecord)
if testCase.wantCode == 0 {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Equal(t, testCase.wantCode, extractFamilyJoinCode(err))
})
}
}

View File

@ -3,7 +3,9 @@ package user
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/kutt"
@ -16,6 +18,7 @@ import (
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/phone"
"github.com/perfect-panel/server/pkg/tool"
"gorm.io/gorm"
)
type QueryUserInfoLogic struct {
@ -41,6 +44,7 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
tool.DeepCopy(resp, u)
ownerEmailMethod := l.fillFamilyContext(resp, u.Id)
var userMethods []types.UserAuthMethod
for _, method := range resp.AuthMethods {
@ -56,11 +60,9 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
}
userMethods = append(userMethods, item)
}
userMethods = appendFamilyOwnerEmailIfNeeded(userMethods, resp.FamilyJoined, ownerEmailMethod)
// 按照指定顺序排序email第一位mobile第二位其他按原顺序
sort.Slice(userMethods, func(i, j int) bool {
return getAuthTypePriority(userMethods[i].AuthType) < getAuthTypePriority(userMethods[j].AuthType)
})
sortUserAuthMethodsByPriority(userMethods)
resp.AuthMethods = userMethods
@ -75,6 +77,109 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
return resp, nil
}
func (l *QueryUserInfoLogic) fillFamilyContext(resp *types.User, userId int64) *user.AuthMethods {
type familyRelation struct {
FamilyId int64
Role uint8
FamilyStatus uint8
OwnerUserId int64
MaxMembers int64
}
var relation familyRelation
relationErr := l.svcCtx.DB.WithContext(l.ctx).
Table("user_family_member").
Select("user_family_member.family_id, user_family_member.role, user_family.status as family_status, user_family.owner_user_id, user_family.max_members").
Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL").
Where("user_family_member.user_id = ? AND user_family_member.deleted_at IS NULL AND user_family_member.status = ?", userId, user.FamilyMemberActive).
First(&relation).Error
if relationErr != nil {
if !errors.Is(relationErr, gorm.ErrRecordNotFound) {
l.Errorw("query family relation failed", logger.Field("user_id", userId), logger.Field("error", relationErr.Error()))
}
return nil
}
resp.FamilyJoined = true
resp.FamilyId = relation.FamilyId
resp.FamilyRole = relation.Role
resp.FamilyRoleName = getFamilyRoleName(relation.Role)
resp.FamilyOwnerUserId = relation.OwnerUserId
resp.FamilyStatus = getFamilyStatusName(relation.FamilyStatus)
resp.FamilyMaxMembers = relation.MaxMembers
var activeMemberCount int64
countErr := l.svcCtx.DB.WithContext(l.ctx).
Table("user_family_member").
Where("family_id = ? AND status = ? AND deleted_at IS NULL", relation.FamilyId, user.FamilyMemberActive).
Count(&activeMemberCount).Error
if countErr != nil {
l.Errorw("count family members failed", logger.Field("family_id", relation.FamilyId), logger.Field("error", countErr.Error()))
} else {
resp.FamilyMemberCount = activeMemberCount
}
ownerEmailMethod, ownerEmailErr := l.svcCtx.UserModel.FindUserAuthMethodByUserId(l.ctx, "email", relation.OwnerUserId)
if ownerEmailErr != nil {
if !errors.Is(ownerEmailErr, gorm.ErrRecordNotFound) {
l.Errorw("query family owner email failed", logger.Field("owner_user_id", relation.OwnerUserId), logger.Field("error", ownerEmailErr.Error()))
}
return nil
}
return ownerEmailMethod
}
func appendFamilyOwnerEmailIfNeeded(methods []types.UserAuthMethod, familyJoined bool, ownerEmailMethod *user.AuthMethods) []types.UserAuthMethod {
if !familyJoined || ownerEmailMethod == nil {
return methods
}
ownerEmail := strings.TrimSpace(ownerEmailMethod.AuthIdentifier)
if ownerEmail == "" {
return methods
}
if hasEmailAuthMethod(methods) {
return methods
}
return append(methods, types.UserAuthMethod{
AuthType: "email",
AuthIdentifier: ownerEmail,
Verified: ownerEmailMethod.Verified,
})
}
func hasEmailAuthMethod(methods []types.UserAuthMethod) bool {
for _, method := range methods {
if strings.EqualFold(strings.TrimSpace(method.AuthType), "email") && strings.TrimSpace(method.AuthIdentifier) != "" {
return true
}
}
return false
}
func sortUserAuthMethodsByPriority(methods []types.UserAuthMethod) {
sort.SliceStable(methods, func(i, j int) bool {
return getAuthTypePriority(methods[i].AuthType) < getAuthTypePriority(methods[j].AuthType)
})
}
func getFamilyRoleName(role uint8) string {
switch role {
case user.FamilyRoleOwner:
return "owner"
case user.FamilyRoleMember:
return "member"
default:
return fmt.Sprintf("role_%d", role)
}
}
func getFamilyStatusName(status uint8) string {
if status == user.FamilyStatusActive {
return "active"
}
return "disabled"
}
// customData 用于解析 SiteConfig.CustomData JSON 字段
// 包含从自定义数据中提取所需的配置项
type customData struct {

View File

@ -0,0 +1,105 @@
package user
import (
"testing"
modelUser "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/types"
"github.com/stretchr/testify/require"
)
func TestAppendFamilyOwnerEmailIfNeeded(t *testing.T) {
testCases := []struct {
name string
methods []types.UserAuthMethod
familyJoined bool
ownerEmailMethod *modelUser.AuthMethods
wantMethodCount int
wantEmailCount int
wantFirstAuthType string
wantFirstAuthValue string
}{
{
name: "inject owner email when member has no email",
methods: []types.UserAuthMethod{
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
},
familyJoined: true,
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "owner@example.com", Verified: true},
wantMethodCount: 2,
wantEmailCount: 1,
wantFirstAuthType: "email",
wantFirstAuthValue: "owner@example.com",
},
{
name: "do not inject when member already has email",
methods: []types.UserAuthMethod{
{AuthType: "email", AuthIdentifier: "member@example.com", Verified: true},
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
},
familyJoined: true,
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "owner@example.com", Verified: true},
wantMethodCount: 2,
wantEmailCount: 1,
wantFirstAuthType: "email",
wantFirstAuthValue: "member@example.com",
},
{
name: "do not inject when owner has no email",
methods: []types.UserAuthMethod{
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
},
familyJoined: true,
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "", Verified: true},
wantMethodCount: 1,
wantEmailCount: 0,
wantFirstAuthType: "device",
},
{
name: "do not inject for non active family relationship",
methods: []types.UserAuthMethod{
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
},
familyJoined: false,
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "owner@example.com", Verified: true},
wantMethodCount: 1,
wantEmailCount: 0,
wantFirstAuthType: "device",
},
{
name: "sort keeps injected email at first position",
methods: []types.UserAuthMethod{
{AuthType: "mobile", AuthIdentifier: "+1234567890", Verified: true},
{AuthType: "device", AuthIdentifier: "dev-1", Verified: true},
},
familyJoined: true,
ownerEmailMethod: &modelUser.AuthMethods{AuthType: "email", AuthIdentifier: "owner@example.com", Verified: true},
wantMethodCount: 3,
wantEmailCount: 1,
wantFirstAuthType: "email",
wantFirstAuthValue: "owner@example.com",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
finalMethods := appendFamilyOwnerEmailIfNeeded(testCase.methods, testCase.familyJoined, testCase.ownerEmailMethod)
sortUserAuthMethodsByPriority(finalMethods)
require.Len(t, finalMethods, testCase.wantMethodCount)
emailCount := 0
for _, method := range finalMethods {
if method.AuthType == "email" {
emailCount++
}
}
require.Equal(t, testCase.wantEmailCount, emailCount)
require.Equal(t, testCase.wantFirstAuthType, finalMethods[0].AuthType)
if testCase.wantFirstAuthValue != "" {
require.Equal(t, testCase.wantFirstAuthValue, finalMethods[0].AuthIdentifier)
}
})
}
}

View File

@ -36,12 +36,12 @@ func init() {
RegisterIPLimit: "Too many registrations",
EmailBindError: "Email already bound",
UserBindInviteCodeExist: "Invite code already bound",
FamilyMemberLimitExceeded: "Family member limit exceeded",
FamilyAlreadyBound: "Family already bound",
FamilyCrossBindForbidden: "Cross-family binding is forbidden",
FamilyNotExist: "Family does not exist",
FamilyStatusInvalid: "Family status is invalid",
FamilyOwnerOperationForbidden: "Owner operation is forbidden",
FamilyMemberLimitExceeded: "家庭成员数量已达上限",
FamilyAlreadyBound: "已绑定家庭组",
FamilyCrossBindForbidden: "禁止跨家庭组绑定",
FamilyNotExist: "家庭组不存在",
FamilyStatusInvalid: "家庭组状态无效",
FamilyOwnerOperationForbidden: "家庭组所有者不允许此操作",
// Node error
NodeExist: "Node already exists",

View File

@ -7,9 +7,9 @@ import (
)
func TestFamilyErrorCodeMessages(t *testing.T) {
require.Equal(t, "Family member limit exceeded", MapErrMsg(FamilyMemberLimitExceeded))
require.Equal(t, "Family already bound", MapErrMsg(FamilyAlreadyBound))
require.Equal(t, "Cross-family binding is forbidden", MapErrMsg(FamilyCrossBindForbidden))
require.Equal(t, "家庭成员数量已达上限", MapErrMsg(FamilyMemberLimitExceeded))
require.Equal(t, "已绑定家庭组", MapErrMsg(FamilyAlreadyBound))
require.Equal(t, "禁止跨家庭组绑定", MapErrMsg(FamilyCrossBindForbidden))
}
func TestFamilyErrorCodeIsRegistered(t *testing.T) {