已完成用户APP首期功能开发
交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
365
backend/api/internal/logic/client/user/family.go
Normal file
365
backend/api/internal/logic/client/user/family.go
Normal file
@@ -0,0 +1,365 @@
|
||||
// 功能:家庭成员邀请确认、按设备授权、撤销和审计;版本: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})
|
||||
}
|
||||
Reference in New Issue
Block a user