366 lines
16 KiB
Go
366 lines
16 KiB
Go
// 功能:家庭成员邀请确认、按设备授权、撤销和审计;版本: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})
|
||
}
|