package roomadmin import ( "context" "database/sql" "errors" "fmt" "strconv" "strings" "time" "google.golang.org/protobuf/proto" "hyapp-admin-server/internal/appctx" roomv1 "hyapp.local/api/proto/room/v1" ) var ( ErrInvalidArgument = errors.New("invalid argument") ErrNotFound = errors.New("not found") ) type Service struct { db *sql.DB userDB *sql.DB } 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"` 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(db *sql.DB, userDB *sql.DB) *Service { return &Service{db: db, userDB: userDB} } func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64, error) { if s.db == nil { return nil, 0, fmt.Errorf("room mysql is not configured") } query = normalizeListQuery(query) appCode := appctx.FromContext(ctx) whereSQL := "FROM room_list_entries rle WHERE rle.app_code = ?" args := []any{appCode} if query.Status == "closed" { whereSQL += " AND rle.status <> ?" args = append(args, "active") } else if query.Status != "" { whereSQL += " AND rle.status = ?" args = append(args, query.Status) } if query.RegionID > 0 { whereSQL += " AND rle.visible_region_id = ?" args = append(args, query.RegionID) } if query.Keyword != "" { like := "%" + query.Keyword + "%" whereSQL += " AND (rle.room_id LIKE ? OR rle.room_short_id LIKE ? OR rle.title LIKE ? OR CAST(rle.owner_user_id AS CHAR) LIKE ?)" args = append(args, like, like, like, like) } total, err := countRows(ctx, s.db, whereSQL, args...) if err != nil { return nil, 0, err } rows, err := s.db.QueryContext(ctx, fmt.Sprintf(` SELECT rle.room_id, rle.room_short_id, rle.visible_region_id, rle.owner_user_id, rle.title, rle.cover_url, rle.mode, rle.status, rle.heat, rle.online_count, rle.seat_count, rle.occupied_seat_count, rle.sort_score, rle.created_at_ms, rle.updated_at_ms %s ORDER BY %s LIMIT ? OFFSET ? `, whereSQL, roomListOrderBy(query)), append(args, query.PageSize, offset(query.Page, query.PageSize))...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]Room, 0, query.PageSize) for rows.Next() { item, err := scanRoom(rows) if err != nil { return nil, 0, err } items = append(items, item) } if err := rows.Err(); err != nil { return nil, 0, err } if err := s.fillRoomDetails(ctx, items); err != nil { return nil, 0, err } return items, total, nil } func (s *Service) GetRoom(ctx context.Context, roomID string) (Room, error) { if s.db == nil { return Room{}, fmt.Errorf("room mysql is not configured") } appCode := appctx.FromContext(ctx) row := s.db.QueryRowContext(ctx, ` SELECT room_id, room_short_id, visible_region_id, owner_user_id, title, cover_url, mode, status, heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms FROM room_list_entries WHERE app_code = ? AND room_id = ? `, appCode, roomID) item, err := scanRoom(row) if errors.Is(err, sql.ErrNoRows) { return Room{}, ErrNotFound } if err != nil { return Room{}, err } items := []Room{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) (Room, error) { if s.db == nil { return Room{}, fmt.Errorf("room mysql is not configured") } updates, snapshotPatch, err := buildRoomUpdate(req) if err != nil { return Room{}, err } if len(updates) == 0 { return s.GetRoom(ctx, roomID) } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return Room{}, err } defer rollbackIfOpen(tx) var currentRoomID string appCode := appctx.FromContext(ctx) if err := tx.QueryRowContext(ctx, `SELECT room_id FROM room_list_entries WHERE app_code = ? AND room_id = ? FOR UPDATE`, appCode, roomID).Scan(¤tRoomID); err != nil { if errors.Is(err, sql.ErrNoRows) { return Room{}, ErrNotFound } return Room{}, err } now := time.Now().UnixMilli() listSets := make([]string, 0, len(updates)+1) listArgs := make([]any, 0, len(updates)+2) roomSets := make([]string, 0, 3) roomArgs := make([]any, 0, 4) for _, update := range updates { if update.listColumn != "" { listSets = append(listSets, update.listColumn+" = ?") listArgs = append(listArgs, update.value) } if update.roomColumn != "" { roomSets = append(roomSets, update.roomColumn+" = ?") roomArgs = append(roomArgs, update.value) } } listSets = append(listSets, "updated_at_ms = ?") listArgs = append(listArgs, now, appCode, roomID) if _, err := tx.ExecContext(ctx, "UPDATE room_list_entries SET "+strings.Join(listSets, ", ")+" WHERE app_code = ? AND room_id = ?", listArgs...); err != nil { return Room{}, err } if len(roomSets) > 0 { roomArgs = append(roomArgs, appCode, roomID) if _, err := tx.ExecContext(ctx, "UPDATE rooms SET "+strings.Join(roomSets, ", ")+" WHERE app_code = ? AND room_id = ?", roomArgs...); err != nil { return Room{}, err } } if err := updateSnapshot(ctx, tx, roomID, snapshotPatch); err != nil { return Room{}, err } if err := tx.Commit(); err != nil { return Room{}, err } return s.GetRoom(ctx, roomID) } func (s *Service) DeleteRoom(ctx context.Context, roomID string) error { if s.db == nil { return fmt.Errorf("room mysql is not configured") } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer rollbackIfOpen(tx) appCode := appctx.FromContext(ctx) result, err := tx.ExecContext(ctx, `DELETE FROM room_list_entries WHERE app_code = ? AND room_id = ?`, appCode, roomID) if err != nil { return err } affected, err := result.RowsAffected() if err != nil { return err } if affected == 0 { return ErrNotFound } if _, err := tx.ExecContext(ctx, `DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`, appCode, roomID); err != nil { return err } if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE app_code = ? AND room_id = ?`, appCode, roomID); err != nil { return err } return tx.Commit() } 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) } 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 updateSnapshot(ctx context.Context, tx *sql.Tx, roomID string, patch snapshotPatch) error { var payload []byte appCode := appctx.FromContext(ctx) err := tx.QueryRowContext(ctx, `SELECT payload FROM room_snapshots WHERE app_code = ? AND room_id = ? FOR UPDATE`, appCode, roomID).Scan(&payload) if errors.Is(err, sql.ErrNoRows) { return nil } if err != nil { return err } snapshot := &roomv1.RoomSnapshot{} if err := proto.Unmarshal(payload, snapshot); err != nil { return err } if snapshot.RoomExt == nil { snapshot.RoomExt = map[string]string{} } if patch.titleSet { snapshot.RoomExt["title"] = patch.title } if patch.coverURLSet { snapshot.RoomExt["cover_url"] = patch.coverURL } if patch.descriptionSet { snapshot.RoomExt["description"] = patch.description } if patch.modeSet { snapshot.Mode = patch.mode } if patch.statusSet { snapshot.Status = patch.status } nextPayload, err := proto.Marshal(snapshot) if err != nil { return err } _, err = tx.ExecContext(ctx, `UPDATE room_snapshots SET payload = ? WHERE app_code = ? AND room_id = ?`, nextPayload, appCode, roomID) return err } type roomScanner interface { Scan(dest ...any) error } func scanRoom(row roomScanner) (Room, error) { var item Room var ownerUserID int64 err := row.Scan( &item.RoomID, &item.RoomShortID, &item.VisibleRegionID, &ownerUserID, &item.Title, &item.CoverURL, &item.Mode, &item.Status, &item.Heat, &item.OnlineCount, &item.SeatCount, &item.OccupiedSeatCount, &item.SortScore, &item.CreatedAtMs, &item.UpdatedAtMs, ) item.OwnerUserID = strconv.FormatInt(ownerUserID, 10) item.Owner = RoomOwner{UserID: item.OwnerUserID} item.RoomContribution = item.Heat if item.Status != "active" { item.Status = "closed" } return item, err } 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 (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 countRows(ctx context.Context, db *sql.DB, whereSQL string, args ...any) (int64, error) { var total int64 err := db.QueryRowContext(ctx, "SELECT COUNT(*) "+whereSQL, args...).Scan(&total) return total, err } func offset(page int, pageSize int) int { return (page - 1) * pageSize } func rollbackIfOpen(tx *sql.Tx) { _ = tx.Rollback() }