Files
platforms/backend/api/internal/logic/client/user/family.go
czl231 3ef33b531d 已完成用户APP首期功能开发
交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
2026-09-13 00:57:32 +08:00

366 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 功能:家庭成员邀请确认、按设备授权、撤销和审计;版本: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})
}