2026-05-08 18:55:47 +08:00

296 lines
9.5 KiB
Go

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())
}