// 功能:用户设备分组资料与本人设备归组,严格隔离账户并保持新增幂等;版本:1.0.0。 package user import ( "errors" "strings" "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 maxDeviceGroups = 20 var ( errDeviceGroupLimit = errcode.NewError(2511, "最多可创建20个设备分组") errDeviceGroupDuplicate = errcode.NewError(2512, "该分组名称已存在") errDeviceGroupRequestChanged = errcode.NewError(2513, "该新增请求已处理,请关闭表单并刷新分组列表") ) type deviceGroupInput struct { Name string `json:"name"` RequestNo string `json:"request_no"` } // validDeviceGroupInput 清理名称并校验新增幂等标识。 func validDeviceGroupInput(input *deviceGroupInput, creating bool) bool { input.Name = strings.TrimSpace(input.Name) if input.Name == "" || utf8.RuneCountInString(input.Name) > 32 { return false } if !creating { return true } parsed, err := uuid.Parse(input.RequestNo) return err == nil && parsed != uuid.Nil && parsed.String() == input.RequestNo } // ListDeviceGroups 返回本人分组及每组当前仍归属本人的设备公开标识。 func ListDeviceGroups(ctx *gin.Context) { account, ok := common.UserAccount(ctx) if !ok { return } var groups []models.UserDeviceGroup if err := impl.DBService.Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable). Order("sort_no, created_at, identity").Limit(maxDeviceGroups).Find(&groups).Error; err != nil { infra.Response.Error(ctx, err) return } groupIDs := make([]uint64, 0, len(groups)) for _, group := range groups { groupIDs = append(groupIDs, group.ID) } devicesByGroup := map[uint64][]string{} if len(groupIDs) > 0 { var devices []models.ProductInfo if err := ownedProductQuery(account.ID).Where("device_group_id IN ? AND device_kind IN ? AND product_status <> ?", groupIDs, []string{"valve", "alarm"}, common.StatusScrapped). Order("created_at, identity").Find(&devices).Error; err != nil { infra.Response.Error(ctx, err) return } for _, device := range devices { devicesByGroup[device.DeviceGroupID] = append(devicesByGroup[device.DeviceGroupID], device.Identity) } } items := make([]gin.H, 0, len(groups)) for _, group := range groups { identities := devicesByGroup[group.ID] if identities == nil { identities = []string{} } items = append(items, gin.H{"identity": group.Identity, "name": group.Name, "sort_no": group.SortNo, "device_identities": identities}) } infra.Response.Success(ctx, items) } // SaveDeviceGroup 新建或改名;账户行锁保证上限、幂等和同名约束串行执行。 func SaveDeviceGroup(ctx *gin.Context) { account, ok := common.UserAccount(ctx) if !ok { return } creating := ctx.Param("identity") == "" var input deviceGroupInput if ctx.ShouldBindJSON(&input) != nil || !validDeviceGroupInput(&input, creating) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } var group models.UserDeviceGroup err := impl.DBService.Transaction(func(tx *gorm.DB) error { if err := lockAddressOwner(tx, account.ID); err != nil { return err } if creating { err := tx.Where("user_account_id = ? AND request_no = ?", account.ID, input.RequestNo).First(&group).Error if err == nil { if group.Status != common.StatusEnable || group.Name != input.Name { return errDeviceGroupRequestChanged } return nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return err } var count int64 if err := tx.Model(&models.UserDeviceGroup{}).Where("user_account_id = ? AND status = ?", account.ID, common.StatusEnable).Count(&count).Error; err != nil { return err } if count >= maxDeviceGroups { return errDeviceGroupLimit } group = models.UserDeviceGroup{Entity: common.NewEntity(common.StatusEnable), UserAccountID: account.ID, Name: input.Name, RequestNo: input.RequestNo, SortNo: int(count)} } else if err := tx.Where("identity = ? AND user_account_id = ? AND status = ?", ctx.Param("identity"), account.ID, common.StatusEnable).First(&group).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errcode.ErrRecordNotFound } return err } var duplicates int64 if err := tx.Model(&models.UserDeviceGroup{}).Where("user_account_id = ? AND status = ? AND name = ? AND identity <> ?", account.ID, common.StatusEnable, input.Name, group.Identity).Count(&duplicates).Error; err != nil { return err } if duplicates > 0 { return errDeviceGroupDuplicate } group.Name = input.Name return tx.Save(&group).Error }) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"identity": group.Identity, "name": group.Name, "sort_no": group.SortNo}) } // DeleteDeviceGroup 先解除本人设备归组再归档分组;重复删除保持成功。 func DeleteDeviceGroup(ctx *gin.Context) { account, ok := common.UserAccount(ctx) if !ok { return } err := impl.DBService.Transaction(func(tx *gorm.DB) error { if err := lockAddressOwner(tx, account.ID); err != nil { return err } var group models.UserDeviceGroup if err := tx.Where("identity = ? AND user_account_id = ?", ctx.Param("identity"), account.ID).First(&group).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errcode.ErrRecordNotFound } return err } if group.Status == common.StatusArchived { return nil } if err := tx.Model(&models.ProductInfo{}).Where("user_account_id = ? AND device_group_id = ?", account.ID, group.ID).Update("device_group_id", 0).Error; err != nil { return err } return tx.Model(&group).Update("status", common.StatusArchived).Error }) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"deleted": true}) } // AssignDeviceGroup 将本人启用智能设备移入本人分组;空标识表示移出分组。 func AssignDeviceGroup(ctx *gin.Context) { account, ok := common.UserAccount(ctx) if !ok { return } var request struct { GroupIdentity string `json:"group_identity"` } if ctx.ShouldBindJSON(&request) != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } err := impl.DBService.Transaction(func(tx *gorm.DB) error { var device models.ProductInfo if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where( "identity = ? AND user_account_id = ? AND status = ? AND device_kind IN ? AND product_status <> ?", ctx.Param("identity"), account.ID, common.StatusEnable, []string{"valve", "alarm"}, common.StatusScrapped, ).First(&device).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errcode.ErrRecordNotFound } return err } groupID := uint64(0) if strings.TrimSpace(request.GroupIdentity) != "" { var group models.UserDeviceGroup if err := tx.Where("identity = ? AND user_account_id = ? AND status = ?", strings.TrimSpace(request.GroupIdentity), account.ID, common.StatusEnable).First(&group).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errcode.ErrRecordNotFound } return err } groupID = group.ID } return tx.Model(&device).Update("device_group_id", groupID).Error }) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"updated": true}) }