// 功能:家庭成员邀请确认、按设备授权、撤销和审计;版本:1.0.0。 package user import ( "errors" "fmt" "strings" "time" "unicode/utf8" "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" ) const ( familyInvitePending = 10 familyInviteAccepted = 20 familyInviteRejected = 30 familyInviteRevoked = 40 ) var ( errFamilySelf = errcode.NewError(2510, "不能邀请当前账号加入自己的家庭") errFamilyDuplicate = errcode.NewError(2511, "该手机号已有待确认或已加入的家庭成员") errFamilyExpired = errcode.NewError(2512, "邀请已失效,请联系房主重新邀请") ) type familyDevicePermissionInput struct { DeviceIdentity string `json:"device_identity"` CanView bool `json:"can_view"` CanAlert bool `json:"can_alert"` CanControl bool `json:"can_control"` } type familyInviteInput struct { Name string `json:"name"` Phone string `json:"phone"` Relationship string `json:"relationship"` RequestNo string `json:"request_no"` Permissions []familyDevicePermissionInput `json:"device_permissions"` } // validFamilyInvite 清理并校验邀请资料;设备授权必须至少包含查看权限。 func validFamilyInvite(input *familyInviteInput) bool { input.Name, input.Phone, input.Relationship = strings.TrimSpace(input.Name), strings.TrimSpace(input.Phone), strings.TrimSpace(input.Relationship) parsed, err := uuid.Parse(input.RequestNo) if err != nil || parsed == uuid.Nil || parsed.String() != input.RequestNo || input.Name == "" || utf8.RuneCountInString(input.Name) > 64 || !common.ValidPhone(input.Phone) || utf8.RuneCountInString(input.Relationship) > 32 { return false } for _, permission := range input.Permissions { if strings.TrimSpace(permission.DeviceIdentity) == "" || (permission.CanAlert || permission.CanControl) && !permission.CanView { return false } } return true } func maskFamilyPhone(phone string) string { if len(phone) == 11 { return phone[:3] + "****" + phone[7:] } return "***" } // familyAudit 在业务事务内追加不可变审计记录。 func familyAudit(tx *gorm.DB, ownerID, memberID, actorID uint64, action, summary string) error { return tx.Create(&models.UserFamilyAudit{Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable}, OwnerUserAccountID: ownerID, FamilyMemberID: memberID, ActorUserAccountID: actorID, Action: action, Summary: summary}).Error } // familyMemberView 返回脱敏成员和当前设备授权,不暴露内部关联键。 func familyMemberView(record models.UserFamilyMember, shares []models.UserDeviceShare, products map[uint64]models.ProductInfo) gin.H { permissions := make([]gin.H, 0, len(shares)) for _, share := range shares { product, ok := products[share.ProductInfoID] if !ok || share.Status != common.StatusEnable { continue } permissions = append(permissions, gin.H{"device_identity": product.Identity, "device_name": product.Name, "device_kind": product.DeviceKind, "can_view": share.CanView, "can_alert": share.CanAlert, "can_control": share.CanControl}) } return gin.H{"identity": record.Identity, "name": record.Name, "phone_masked": maskFamilyPhone(record.Phone), "relationship": record.Relationship, "invite_status": record.InviteStatus, "expires_at": record.ExpiresAt, "device_permissions": permissions} } // FamilyDashboard 返回本人作为房主的成员、设备和权限汇总。 func FamilyDashboard(ctx *gin.Context) { account, ok := common.UserAccount(ctx) if !ok { return } var members []models.UserFamilyMember if err := impl.DBService.Where("owner_user_account_id = ? AND status = ? AND invite_status IN ?", account.ID, common.StatusEnable, []int{familyInvitePending, familyInviteAccepted}).Order("created_at, identity").Find(&members).Error; err != nil { infra.Response.Error(ctx, err) return } var devices []models.ProductInfo if err := ownedProductQuery(account.ID).Where("device_kind IN ? AND product_status <> ?", []string{"valve", "alarm"}, common.StatusScrapped).Order("created_at, identity").Find(&devices).Error; err != nil { infra.Response.Error(ctx, err) return } memberIDs := make([]uint64, 0, len(members)) for _, member := range members { memberIDs = append(memberIDs, member.ID) } var shares []models.UserDeviceShare if len(memberIDs) > 0 { if err := impl.DBService.Where("family_member_id IN ? AND status = ?", memberIDs, common.StatusEnable).Find(&shares).Error; err != nil { infra.Response.Error(ctx, err) return } } productMap := make(map[uint64]models.ProductInfo, len(devices)) for _, device := range devices { productMap[device.ID] = device } sharesByMember := map[uint64][]models.UserDeviceShare{} shareCounts := map[uint64]int{} for _, share := range shares { sharesByMember[share.FamilyMemberID] = append(sharesByMember[share.FamilyMemberID], share) if share.CanView { shareCounts[share.ProductInfoID]++ } } memberViews := make([]gin.H, 0, len(members)+1) memberViews = append(memberViews, gin.H{"identity": account.Identity, "name": account.Name, "phone_masked": maskFamilyPhone(account.Phone), "relationship": "房主", "invite_status": familyInviteAccepted, "is_owner": true, "device_permissions": []gin.H{}}) for _, member := range members { memberViews = append(memberViews, familyMemberView(member, sharesByMember[member.ID], productMap)) } deviceViews := make([]gin.H, 0, len(devices)) for _, device := range devices { deviceViews = append(deviceViews, gin.H{"identity": device.Identity, "name": device.Name, "kind": device.DeviceKind, "share_count": shareCounts[device.ID], "mapping_configured": device.VendorDeviceID != ""}) } incoming, err := incomingFamilyInvitations(account) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"household_name": account.Name + "的家", "owner_name": account.Name, "member_count": len(members), "device_count": len(deviceViews), "members": memberViews, "devices": deviceViews, "incoming_invitations": incoming}) } // incomingFamilyInvitations 读取当前手机号收到的有效邀请及房主名称。 func incomingFamilyInvitations(account models.UserAccount) ([]gin.H, error) { var rows []models.UserFamilyMember if err := impl.DBService.Where("phone = ? AND status = ? AND invite_status = ? AND expires_at > ?", account.Phone, common.StatusEnable, familyInvitePending, time.Now()).Order("created_at desc").Find(&rows).Error; err != nil { return nil, err } ownerIDs := make([]uint64, 0, len(rows)) for _, row := range rows { ownerIDs = append(ownerIDs, row.OwnerUserAccountID) } owners := map[uint64]string{} if len(ownerIDs) > 0 { var users []models.UserAccount if err := impl.DBService.Where("id IN ?", ownerIDs).Find(&users).Error; err != nil { return nil, err } for _, user := range users { owners[user.ID] = user.Name } } items := make([]gin.H, 0, len(rows)) for _, row := range rows { items = append(items, gin.H{"identity": row.Identity, "owner_name": owners[row.OwnerUserAccountID], "name": row.Name, "relationship": row.Relationship, "expires_at": row.ExpiresAt}) } return items, nil } // InviteFamilyMember 创建七天有效邀请及预设权限;成员接受前不会获得访问权。 func InviteFamilyMember(ctx *gin.Context) { account, ok := common.UserAccount(ctx) if !ok { return } var input familyInviteInput if ctx.ShouldBindJSON(&input) != nil || !validFamilyInvite(&input) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } if input.Phone == account.Phone { infra.Response.Error(ctx, errFamilySelf) return } var record models.UserFamilyMember err := impl.DBService.Transaction(func(tx *gorm.DB) error { if err := lockAddressOwner(tx, account.ID); err != nil { return err } if err := tx.Where("owner_user_account_id = ? AND request_no = ?", account.ID, input.RequestNo).First(&record).Error; err == nil { return nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } var duplicate int64 if err := tx.Model(&models.UserFamilyMember{}).Where("owner_user_account_id = ? AND phone = ? AND status = ? AND invite_status IN ?", account.ID, input.Phone, common.StatusEnable, []int{familyInvitePending, familyInviteAccepted}).Count(&duplicate).Error; err != nil { return err } if duplicate > 0 { return errFamilyDuplicate } record = models.UserFamilyMember{Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable}, OwnerUserAccountID: account.ID, Name: input.Name, Phone: input.Phone, Relationship: input.Relationship, InviteStatus: familyInvitePending, RequestNo: input.RequestNo, ExpiresAt: time.Now().Add(7 * 24 * time.Hour)} if err := tx.Create(&record).Error; err != nil { return err } if err := replaceFamilyPermissions(tx, account.ID, record.ID, input.Permissions); err != nil { return err } return familyAudit(tx, account.ID, record.ID, account.ID, "invite", fmt.Sprintf("邀请%s,预设%d台设备权限", record.Name, len(input.Permissions))) }) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"identity": record.Identity, "invite_status": record.InviteStatus, "expires_at": record.ExpiresAt}) } // replaceFamilyPermissions 仅允许房主名下设备,空列表表示撤销全部设备授权。 func replaceFamilyPermissions(tx *gorm.DB, ownerID, memberID uint64, inputs []familyDevicePermissionInput) error { if err := tx.Model(&models.UserDeviceShare{}).Where("family_member_id = ? AND status = ?", memberID, common.StatusEnable).Update("status", common.StatusArchived).Error; err != nil { return err } seen := map[string]bool{} for _, input := range inputs { identity := strings.TrimSpace(input.DeviceIdentity) if seen[identity] { return errcode.ErrInvalidArgument } seen[identity] = true var product models.ProductInfo if err := tx.Where("identity = ? AND user_account_id = ? AND status = ? AND device_kind IN ? AND product_status <> ?", identity, ownerID, common.StatusEnable, []string{"valve", "alarm"}, common.StatusScrapped).First(&product).Error; err != nil { return errcode.ErrPermissionDenied } share := models.UserDeviceShare{Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable}, FamilyMemberID: memberID, ProductInfoID: product.ID, CanView: input.CanView, CanAlert: input.CanAlert, CanControl: input.CanControl} if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "family_member_id"}, {Name: "product_info_id"}}, DoUpdates: clause.Assignments(map[string]interface{}{"status": common.StatusEnable, "can_view": input.CanView, "can_alert": input.CanAlert, "can_control": input.CanControl, "updated_at": time.Now()})}).Create(&share).Error; err != nil { return err } } return nil } // UpdateFamilyPermissions 调整成员设备范围,权限立即生效并写入审计。 func UpdateFamilyPermissions(ctx *gin.Context) { account, ok := common.UserAccount(ctx) if !ok { return } var input struct { Permissions []familyDevicePermissionInput `json:"device_permissions"` } if ctx.ShouldBindJSON(&input) != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } for _, permission := range input.Permissions { if strings.TrimSpace(permission.DeviceIdentity) == "" || (permission.CanAlert || permission.CanControl) && !permission.CanView { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } } err := impl.DBService.Transaction(func(tx *gorm.DB) error { var member models.UserFamilyMember if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND owner_user_account_id = ? AND status = ? AND invite_status IN ?", ctx.Param("identity"), account.ID, common.StatusEnable, []int{familyInvitePending, familyInviteAccepted}).First(&member).Error; err != nil { return errcode.ErrRecordNotFound } if err := replaceFamilyPermissions(tx, account.ID, member.ID, input.Permissions); err != nil { return err } return familyAudit(tx, account.ID, member.ID, account.ID, "update_permissions", fmt.Sprintf("调整%s的设备权限,共%d台", member.Name, len(input.Permissions))) }) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"updated": true}) } // RevokeFamilyMember 撤销邀请或移除成员,同时停用其全部设备授权。 func RevokeFamilyMember(ctx *gin.Context) { account, ok := common.UserAccount(ctx) if !ok { return } err := impl.DBService.Transaction(func(tx *gorm.DB) error { var member models.UserFamilyMember if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND owner_user_account_id = ? AND status = ? AND invite_status IN ?", ctx.Param("identity"), account.ID, common.StatusEnable, []int{familyInvitePending, familyInviteAccepted}).First(&member).Error; err != nil { return errcode.ErrRecordNotFound } now := time.Now() if err := tx.Model(&member).Updates(map[string]interface{}{"invite_status": familyInviteRevoked, "revoked_at": now, "status": common.StatusArchived}).Error; err != nil { return err } if err := tx.Model(&models.UserDeviceShare{}).Where("family_member_id = ? AND status = ?", member.ID, common.StatusEnable).Update("status", common.StatusArchived).Error; err != nil { return err } return familyAudit(tx, account.ID, member.ID, account.ID, "revoke", "撤销成员"+member.Name) }) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"revoked": true}) } // ListFamilyInvitations 返回当前手机号尚未过期的邀请。 func ListFamilyInvitations(ctx *gin.Context) { account, ok := common.UserAccount(ctx) if !ok { return } items, err := incomingFamilyInvitations(account) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, items) } // RespondFamilyInvitation 仅受邀手机号本人可接受或拒绝邀请。 func RespondFamilyInvitation(ctx *gin.Context) { account, ok := common.UserAccount(ctx) if !ok { return } var input struct { Accept bool `json:"accept"` } if ctx.ShouldBindJSON(&input) != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } err := impl.DBService.Transaction(func(tx *gorm.DB) error { var member models.UserFamilyMember if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ? AND phone = ? AND status = ? AND invite_status = ?", ctx.Param("identity"), account.Phone, common.StatusEnable, familyInvitePending).First(&member).Error; err != nil { return errcode.ErrRecordNotFound } if time.Now().After(member.ExpiresAt) { return errFamilyExpired } status, action := familyInviteRejected, "reject" updates := map[string]interface{}{"invite_status": status, "status": common.StatusArchived} if input.Accept { status, action = familyInviteAccepted, "accept" now := time.Now() updates = map[string]interface{}{"invite_status": status, "member_user_account_id": account.ID, "accepted_at": now} } if err := tx.Model(&member).Updates(updates).Error; err != nil { return err } if !input.Accept { if err := tx.Model(&models.UserDeviceShare{}).Where("family_member_id = ?", member.ID).Update("status", common.StatusArchived).Error; err != nil { return err } } return familyAudit(tx, member.OwnerUserAccountID, member.ID, account.ID, action, "成员响应家庭邀请") }) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"accepted": input.Accept}) }