package regionimgroup import ( "context" "crypto/sha1" "encoding/hex" "errors" "fmt" "net/http" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/service/tencentim" "chatapp3-golang/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) // ListRegionIMGroups 返回已配置的区域 IM 群组。 func (s *Service) ListRegionIMGroups(ctx context.Context, sysOrigin string) ([]RegionIMGroupResponse, error) { sysOrigin = normalizeSysOrigin(sysOrigin) var rows []model.VoiceRoomRegionIMGroup if err := s.db.WithContext(ctx). Where("sys_origin = ?", sysOrigin). Order("region_code asc"). Find(&rows).Error; err != nil { return nil, err } result := make([]RegionIMGroupResponse, 0, len(rows)) for _, row := range rows { result = append(result, regionIMGroupResponse(row)) } return result, nil } // GetCurrentRegionIMGroup 返回当前登录用户所在区域的 IM 群组,供 App 加入区域广播群。 func (s *Service) GetCurrentRegionIMGroup(ctx context.Context, user AuthUser) (*RegionIMGroupResponse, error) { sysOrigin := normalizeSysOrigin(user.SysOrigin) regionCode, err := s.resolveUserRegion(ctx, sysOrigin, user.UserID) if err != nil { return nil, err } row, err := s.loadActiveRegionIMGroup(ctx, sysOrigin, regionCode) if errors.Is(err, gorm.ErrRecordNotFound) { return &RegionIMGroupResponse{ SysOrigin: sysOrigin, RegionCode: regionCode, Status: imGroupStatusPending, Configured: false, }, nil } if err != nil { return nil, err } resp := regionIMGroupResponse(*row) resp.Configured = strings.TrimSpace(resp.GroupID) != "" return &resp, nil } // SaveRegionIMGroup 保存人工录入的区域 IM 群组。 func (s *Service) SaveRegionIMGroup(ctx context.Context, req RegionIMGroupRequest) (*RegionIMGroupResponse, error) { row, err := s.buildRegionIMGroupRow(req, imGroupStatusActive, "") if err != nil { return nil, err } if strings.TrimSpace(row.GroupID) == "" { row.Status = imGroupStatusPending } if err := s.upsertRegionIMGroup(ctx, row); err != nil { return nil, err } return s.loadRegionIMGroupResponse(ctx, row.SysOrigin, row.RegionCode) } // CreateRegionIMGroup 调用腾讯 IM 创建当前区域通用广播群并落库。 func (s *Service) CreateRegionIMGroup(ctx context.Context, req RegionIMGroupRequest) (*RegionIMGroupResponse, error) { row, err := s.buildRegionIMGroupRow(req, imGroupStatusPending, "") if err != nil { return nil, err } existing, err := s.loadRegionIMGroup(ctx, row.SysOrigin, row.RegionCode) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } if existing != nil && existing.Status == imGroupStatusActive && strings.TrimSpace(existing.GroupID) != "" && strings.TrimSpace(req.GroupID) == "" { resp := regionIMGroupResponse(*existing) return &resp, nil } if s.im == nil { return nil, mapTencentIMError(tencentim.ErrNotConfigured) } createdGroupID, createErr := s.im.CreateAVChatRoom(ctx, row.GroupID, row.GroupName) if createErr != nil { row.Status = imGroupStatusFailed row.LastError = trimErrorMessage(createErr.Error()) _ = s.upsertRegionIMGroup(context.Background(), row) return nil, mapTencentIMError(createErr) } row.GroupID = createdGroupID row.Status = imGroupStatusActive row.LastError = "" if err := s.upsertRegionIMGroup(ctx, row); err != nil { return nil, err } return s.loadRegionIMGroupResponse(ctx, row.SysOrigin, row.RegionCode) } // SendRegionCustomMessage 向指定区域通用 IM 群发送自定义消息。 func (s *Service) SendRegionCustomMessage(ctx context.Context, sysOrigin string, regionCode string, body any) (string, error) { if s == nil || s.im == nil { return "", tencentim.ErrNotConfigured } group, err := s.loadActiveRegionIMGroup(ctx, sysOrigin, regionCode) if errors.Is(err, gorm.ErrRecordNotFound) { return "", fmt.Errorf("region im group is not configured sysOrigin=%s regionCode=%s", normalizeSysOrigin(sysOrigin), normalizeRegionCode(regionCode)) } if err != nil { return "", err } if err := s.im.SendCustomGroupMessage(ctx, group.GroupID, body); err != nil { return "", err } return group.GroupID, nil } func (s *Service) buildRegionIMGroupRow(req RegionIMGroupRequest, status string, lastError string) (model.VoiceRoomRegionIMGroup, error) { sysOrigin := normalizeSysOrigin(req.SysOrigin) regionCode := normalizeRegionCode(req.RegionCode) if regionCode == "" { return model.VoiceRoomRegionIMGroup{}, NewAppError(http.StatusBadRequest, "missing_region_code", "regionCode is required") } groupID := strings.TrimSpace(req.GroupID) if groupID == "" { groupID = defaultRegionIMGroupID(sysOrigin, regionCode) } groupName := strings.TrimSpace(req.GroupName) if groupName == "" { groupName = defaultRegionIMGroupName(sysOrigin, regionCode, req.RegionName) } id, err := utils.NextID() if err != nil { return model.VoiceRoomRegionIMGroup{}, err } now := time.Now() return model.VoiceRoomRegionIMGroup{ ID: id, SysOrigin: sysOrigin, RegionCode: regionCode, RegionName: strings.TrimSpace(req.RegionName), GroupID: groupID, GroupName: groupName, Status: status, LastError: trimErrorMessage(lastError), CreateTime: now, UpdateTime: now, }, nil } func (s *Service) upsertRegionIMGroup(ctx context.Context, row model.VoiceRoomRegionIMGroup) error { now := time.Now() row.UpdateTime = now if row.CreateTime.IsZero() { row.CreateTime = now } return s.db.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{ {Name: "sys_origin"}, {Name: "region_code"}, }, DoUpdates: clause.Assignments(map[string]any{ "region_name": row.RegionName, "group_id": row.GroupID, "group_name": row.GroupName, "status": row.Status, "last_error": row.LastError, "update_time": row.UpdateTime, }), }).Create(&row).Error } func (s *Service) loadRegionIMGroupResponse(ctx context.Context, sysOrigin string, regionCode string) (*RegionIMGroupResponse, error) { row, err := s.loadRegionIMGroup(ctx, sysOrigin, regionCode) if err != nil { return nil, err } resp := regionIMGroupResponse(*row) return &resp, nil } func (s *Service) loadRegionIMGroup(ctx context.Context, sysOrigin string, regionCode string) (*model.VoiceRoomRegionIMGroup, error) { var row model.VoiceRoomRegionIMGroup err := s.db.WithContext(ctx). Where("sys_origin = ? AND region_code = ?", normalizeSysOrigin(sysOrigin), normalizeRegionCode(regionCode)). First(&row).Error if err != nil { return nil, err } return &row, nil } func (s *Service) loadActiveRegionIMGroup(ctx context.Context, sysOrigin string, regionCode string) (*model.VoiceRoomRegionIMGroup, error) { var row model.VoiceRoomRegionIMGroup err := s.db.WithContext(ctx). Where("sys_origin = ? AND region_code = ? AND status = ? AND group_id <> ?", normalizeSysOrigin(sysOrigin), normalizeRegionCode(regionCode), imGroupStatusActive, ""). First(&row).Error if err != nil { return nil, err } return &row, nil } func regionIMGroupResponse(row model.VoiceRoomRegionIMGroup) RegionIMGroupResponse { return RegionIMGroupResponse{ ID: row.ID, SysOrigin: row.SysOrigin, RegionCode: row.RegionCode, RegionName: row.RegionName, GroupID: row.GroupID, GroupName: row.GroupName, Status: row.Status, LastError: row.LastError, CreateTime: formatTime(row.CreateTime), UpdateTime: formatTime(row.UpdateTime), Configured: row.Status == imGroupStatusActive && strings.TrimSpace(row.GroupID) != "", } } func normalizeRegionCode(value string) string { return strings.ToUpper(strings.TrimSpace(value)) } func defaultRegionIMGroupID(sysOrigin string, regionCode string) string { normalizedSysOrigin := normalizeSysOrigin(sysOrigin) normalizedRegionCode := normalizeRegionCode(regionCode) groupSysOrigin := sanitizeIMGroupIDPart(normalizedSysOrigin) if groupSysOrigin == "" { groupSysOrigin = "SYS" } groupRegionCode := sanitizeIMGroupIDSegment(normalizedRegionCode) if groupRegionCode == "" { groupRegionCode = "REGION" } raw := "VRRP_" + groupSysOrigin + "_" + groupRegionCode if groupRegionCode != normalizedRegionCode { raw += "_" + shortRegionHash(normalizedSysOrigin, normalizedRegionCode) } if len(raw) <= 48 { return raw } return raw[:39] + "_" + shortRegionHash(normalizedSysOrigin, normalizedRegionCode) } func shortRegionHash(sysOrigin string, regionCode string) string { sum := sha1.Sum([]byte(normalizeSysOrigin(sysOrigin) + "|" + normalizeRegionCode(regionCode))) return strings.ToUpper(hex.EncodeToString(sum[:])[:8]) } func sanitizeIMGroupIDSegment(value string) string { value = strings.ToUpper(strings.TrimSpace(value)) var builder strings.Builder for _, r := range value { if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { builder.WriteRune(r) continue } builder.WriteByte('_') } return strings.Trim(builder.String(), "_-") } func sanitizeIMGroupIDPart(value string) string { result := sanitizeIMGroupIDSegment(value) if result == "" { return "VRRP_GROUP" } return result } func defaultRegionIMGroupName(sysOrigin string, regionCode string, regionName string) string { name := strings.TrimSpace(regionName) if name == "" { name = normalizeRegionCode(regionCode) } return fmt.Sprintf("region-im-group-%s-%s", normalizeSysOrigin(sysOrigin), name) } func mapTencentIMError(err error) *AppError { if errors.Is(err, tencentim.ErrNotConfigured) { return NewAppError(http.StatusServiceUnavailable, "tencent_im_not_configured", "Tencent IM config is missing") } return NewAppError(http.StatusBadGateway, "tencent_im_request_failed", err.Error()) }