package roomadmin import ( "context" "database/sql" "errors" "fmt" "strconv" "strings" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/roomclient" "hyapp-admin-server/internal/modules/shared" ) var ( ErrInvalidArgument = errors.New("invalid argument") ErrNotFound = errors.New("not found") ) type Service struct { userDB *sql.DB roomClient roomclient.Client } type RoomOwner struct { Avatar string `json:"avatar"` DisplayUserID string `json:"displayUserId"` UserID string `json:"userId"` Username string `json:"username"` } type Room struct { CoverURL string `json:"coverUrl"` ClosedAtMs int64 `json:"closedAtMs,omitempty"` ClosedByAdminID uint `json:"closedByAdminId,omitempty"` ClosedByAdminName string `json:"closedByAdminName,omitempty"` CloseReason string `json:"closeReason,omitempty"` CreatedAtMs int64 `json:"createdAtMs"` Heat int64 `json:"heat"` Mode string `json:"mode"` OccupiedSeatCount int32 `json:"occupiedSeatCount"` OnlineCount int32 `json:"onlineCount"` Owner RoomOwner `json:"owner"` OwnerUserID string `json:"ownerUserId"` RegionName string `json:"regionName"` RoomID string `json:"roomId"` RoomShortID string `json:"roomShortId"` RoomContribution int64 `json:"roomContribution"` SeatCount int32 `json:"seatCount"` SortScore int64 `json:"sortScore"` Status string `json:"status"` Title string `json:"title"` UpdatedAtMs int64 `json:"updatedAtMs"` VisibleRegionID int64 `json:"visibleRegionId"` } func NewService(userDB *sql.DB, roomClient roomclient.Client) *Service { return &Service{userDB: userDB, roomClient: roomClient} } func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64, error) { if s.roomClient == nil { return nil, 0, fmt.Errorf("room service client is not configured") } query = normalizeListQuery(query) result, err := s.roomClient.ListRooms(ctx, roomclient.ListRoomsRequest{ Page: query.Page, PageSize: query.PageSize, Keyword: query.Keyword, Status: query.Status, RegionID: query.RegionID, SortBy: query.SortBy, SortDirection: query.SortDirection, }) if err != nil { return nil, 0, err } items := roomsFromClient(result.Rooms) if err := s.fillRoomDetails(ctx, items); err != nil { return nil, 0, err } return items, result.Total, nil } func (s *Service) GetRoom(ctx context.Context, roomID string) (Room, error) { item, err := s.roomClient.GetRoom(ctx, roomclient.GetRoomRequest{RoomID: roomID}) if err != nil { return Room{}, mapRoomClientError(err) } items := []Room{roomFromClient(*item)} if err := s.fillRoomDetails(ctx, items); err != nil { return Room{}, err } return items[0], nil } func (s *Service) UpdateRoom(ctx context.Context, roomID string, req updateRoomRequest, actor shared.Actor, requestID string) (Room, error) { if s.roomClient == nil { return Room{}, fmt.Errorf("room service client is not configured") } if _, _, err := buildRoomUpdate(req); err != nil { return Room{}, err } if !roomUpdateHasChange(req) { return s.GetRoom(ctx, roomID) } _, err := s.roomClient.UpdateRoom(ctx, roomclient.UpdateRoomRequest{ RequestID: requestID, RoomID: roomID, ActorUserID: int64(actor.UserID), Title: req.Title, CoverURL: req.CoverURL, Description: req.Description, Mode: req.Mode, Status: req.Status, VisibleRegionID: req.VisibleRegionID, CloseReason: valueOrEmpty(req.CloseReason), AdminID: uint64(actor.UserID), AdminName: actor.Username, }) if err != nil { return Room{}, mapRoomClientError(err) } return s.GetRoom(ctx, roomID) } func (s *Service) DeleteRoom(ctx context.Context, roomID string, actor shared.Actor, requestID string) error { if s.roomClient == nil { return fmt.Errorf("room service client is not configured") } _, err := s.roomClient.DeleteRoom(ctx, roomclient.DeleteRoomRequest{ RequestID: requestID, RoomID: roomID, ActorUserID: int64(actor.UserID), AdminID: uint64(actor.UserID), AdminName: actor.Username, }) return mapRoomClientError(err) } func roomUpdateHasChange(req updateRoomRequest) bool { return req.Title != nil || req.CoverURL != nil || req.Description != nil || req.Mode != nil || req.Status != nil || req.VisibleRegionID != nil } func roomsFromClient(items []roomclient.Room) []Room { rooms := make([]Room, 0, len(items)) for _, item := range items { rooms = append(rooms, roomFromClient(item)) } return rooms } func roomFromClient(item roomclient.Room) Room { status := item.Status if status != "active" { status = "closed" } ownerUserID := strconv.FormatInt(item.OwnerUserID, 10) return Room{ RoomID: item.RoomID, RoomShortID: item.RoomShortID, VisibleRegionID: item.VisibleRegionID, OwnerUserID: ownerUserID, Owner: RoomOwner{UserID: ownerUserID}, Title: item.Title, CoverURL: item.CoverURL, Mode: item.Mode, Status: status, CloseReason: item.CloseReason, ClosedByAdminID: uint(item.ClosedByAdminID), ClosedByAdminName: item.ClosedByAdminName, ClosedAtMs: item.ClosedAtMS, Heat: item.Heat, RoomContribution: item.Heat, OnlineCount: item.OnlineCount, SeatCount: item.SeatCount, OccupiedSeatCount: item.OccupiedSeatCount, SortScore: item.SortScore, CreatedAtMs: item.CreatedAtMS, UpdatedAtMs: item.UpdatedAtMS, } } func mapRoomClientError(err error) error { if err == nil { return nil } if st, ok := status.FromError(err); ok { switch st.Code() { case codes.NotFound: return ErrNotFound case codes.InvalidArgument, codes.FailedPrecondition: return fmt.Errorf("%w: %s", ErrInvalidArgument, st.Message()) } } return err } type roomUpdate struct { listColumn string roomColumn string value any } type snapshotPatch struct { coverURLSet bool coverURL string descriptionSet bool description string modeSet bool mode string statusSet bool status string titleSet bool title string visibleRegionID int64 regionSet bool } func buildRoomUpdate(req updateRoomRequest) ([]roomUpdate, snapshotPatch, error) { updates := make([]roomUpdate, 0, 6) var patch snapshotPatch if req.Title != nil { value := strings.TrimSpace(*req.Title) if value == "" { return nil, patch, fmt.Errorf("%w: 请输入房间名称", ErrInvalidArgument) } if len(value) > 128 { return nil, patch, fmt.Errorf("%w: 房间名称不能超过 128 个字符", ErrInvalidArgument) } updates = append(updates, roomUpdate{listColumn: "title", value: value}) patch.titleSet = true patch.title = value } if req.CoverURL != nil { value := strings.TrimSpace(*req.CoverURL) if len(value) > 512 { return nil, patch, fmt.Errorf("%w: 房间头像不能超过 512 个字符", ErrInvalidArgument) } updates = append(updates, roomUpdate{listColumn: "cover_url", value: value}) patch.coverURLSet = true patch.coverURL = value } if req.Description != nil { value := strings.TrimSpace(*req.Description) if len(value) > 512 { return nil, patch, fmt.Errorf("%w: 房间简介不能超过 512 个字符", ErrInvalidArgument) } patch.descriptionSet = true patch.description = value } if req.Mode != nil { value := strings.TrimSpace(*req.Mode) if value == "" { return nil, patch, fmt.Errorf("%w: 请输入房间模式", ErrInvalidArgument) } if len(value) > 64 { return nil, patch, fmt.Errorf("%w: 房间模式不能超过 64 个字符", ErrInvalidArgument) } updates = append(updates, roomUpdate{listColumn: "mode", roomColumn: "mode", value: value}) patch.modeSet = true patch.mode = value } if req.Status != nil { value, ok := normalizeRoomStatus(*req.Status) if !ok { return nil, patch, fmt.Errorf("%w: 房间状态不正确", ErrInvalidArgument) } if value == "closed" { reason := strings.TrimSpace(valueOrEmpty(req.CloseReason)) if reason == "" { return nil, patch, fmt.Errorf("%w: 请输入关闭原因", ErrInvalidArgument) } if len(reason) > 512 { return nil, patch, fmt.Errorf("%w: 关闭原因不能超过 512 个字符", ErrInvalidArgument) } } updates = append(updates, roomUpdate{listColumn: "status", roomColumn: "status", value: value}) patch.statusSet = true patch.status = value } if req.VisibleRegionID != nil { value := *req.VisibleRegionID if value < 0 { return nil, patch, fmt.Errorf("%w: 区域 ID 不正确", ErrInvalidArgument) } updates = append(updates, roomUpdate{listColumn: "visible_region_id", roomColumn: "visible_region_id", value: value}) patch.regionSet = true patch.visibleRegionID = value } return updates, patch, nil } func normalizeListQuery(query listQuery) listQuery { if query.Page < 1 { query.Page = 1 } if query.PageSize < 1 { query.PageSize = 20 } if query.PageSize > 100 { query.PageSize = 100 } query.Keyword = strings.TrimSpace(query.Keyword) query.Status = strings.ToLower(strings.TrimSpace(query.Status)) if status, ok := normalizeRoomStatus(query.Status); ok { query.Status = status } query.SortBy = normalizeRoomListSortBy(query.SortBy) query.SortDirection = normalizeRoomListSortDirection(query.SortDirection) return query } func roomListOrderBy(query listQuery) string { // ORDER BY 字段只能来自 normalizeRoomListSortBy 的白名单,避免把 HTTP 查询值拼进 SQL。 if query.SortBy == "room_contribution" { if query.SortDirection == "asc" { return "rle.heat ASC, rle.created_at_ms DESC, rle.room_id DESC" } return "rle.heat DESC, rle.created_at_ms DESC, rle.room_id DESC" } return "rle.created_at_ms DESC, rle.room_id DESC" } func normalizeRoomListSortBy(sortBy string) string { switch strings.ToLower(strings.TrimSpace(sortBy)) { case "", "created_at", "createdat": return "" case "room_contribution", "roomcontribution", "contribution", "heat": return "room_contribution" default: return "" } } func normalizeRoomListSortDirection(direction string) string { switch strings.ToLower(strings.TrimSpace(direction)) { case "asc", "ascending": return "asc" default: return "desc" } } func normalizeRoomStatus(status string) (string, bool) { switch strings.ToLower(strings.TrimSpace(status)) { case "": return "", true case "active", "normal": return "active", true case "closed": return "closed", true default: return "", false } } func valueOrEmpty(value *string) string { if value == nil { return "" } return *value } func (s *Service) fillRoomDetails(ctx context.Context, items []Room) error { if s.userDB == nil || len(items) == 0 { return nil } ownerIDs := make([]int64, 0, len(items)) regionIDs := make([]int64, 0, len(items)) for _, item := range items { if ownerID, err := strconv.ParseInt(item.OwnerUserID, 10, 64); err == nil && ownerID > 0 { ownerIDs = append(ownerIDs, ownerID) } if item.VisibleRegionID > 0 { regionIDs = append(regionIDs, item.VisibleRegionID) } } owners, err := s.queryRoomOwners(ctx, uniqueInt64s(ownerIDs)) if err != nil { return err } regions, err := s.queryRegionNames(ctx, uniqueInt64s(regionIDs)) if err != nil { return err } for index := range items { if ownerID, err := strconv.ParseInt(items[index].OwnerUserID, 10, 64); err == nil { if owner, ok := owners[ownerID]; ok { items[index].Owner = owner } } if name, ok := regions[items[index].VisibleRegionID]; ok { items[index].RegionName = name } } return nil } func (s *Service) queryRoomOwners(ctx context.Context, ownerIDs []int64) (map[int64]RoomOwner, error) { owners := make(map[int64]RoomOwner, len(ownerIDs)) if len(ownerIDs) == 0 { return owners, nil } args := append([]any{appctx.FromContext(ctx)}, int64Args(ownerIDs)...) rows, err := s.userDB.QueryContext(ctx, ` SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '') FROM users WHERE app_code = ? AND user_id IN (`+placeholders(len(ownerIDs))+`) `, args...) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var userID int64 var owner RoomOwner if err := rows.Scan(&userID, &owner.DisplayUserID, &owner.Username, &owner.Avatar); err != nil { return nil, err } owner.UserID = strconv.FormatInt(userID, 10) owners[userID] = owner } return owners, rows.Err() } func (s *Service) queryRegionNames(ctx context.Context, regionIDs []int64) (map[int64]string, error) { regions := make(map[int64]string, len(regionIDs)) if len(regionIDs) == 0 { return regions, nil } args := append([]any{appctx.FromContext(ctx)}, int64Args(regionIDs)...) rows, err := s.userDB.QueryContext(ctx, ` SELECT region_id, name FROM regions WHERE app_code = ? AND region_id IN (`+placeholders(len(regionIDs))+`) `, args...) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var regionID int64 var name string if err := rows.Scan(®ionID, &name); err != nil { return nil, err } regions[regionID] = name } return regions, rows.Err() } func uniqueInt64s(values []int64) []int64 { seen := make(map[int64]struct{}, len(values)) unique := make([]int64, 0, len(values)) for _, value := range values { if _, ok := seen[value]; ok { continue } seen[value] = struct{}{} unique = append(unique, value) } return unique } func placeholders(count int) string { if count <= 0 { return "" } items := make([]string, count) for index := range items { items[index] = "?" } return strings.Join(items, ",") } func int64Args(values []int64) []any { args := make([]any, 0, len(values)) for _, value := range values { args = append(args, value) } return args } func offset(page int, pageSize int) int { return (page - 1) * pageSize }