package roomadmin import ( "context" "database/sql" "errors" "fmt" "strconv" "strings" "time" "hyapp-admin-server/internal/appctx" ) const ( roomPinStatusActive = "active" roomPinStatusCancelled = "cancelled" roomPinStatusExpired = "expired" roomPinStatusAll = "all" defaultRoomPinDays = int64(30) maxRoomPinDays = int64(3650) roomPinDayMillis = int64(24 * 60 * 60 * 1000) ) type RoomPinRoom struct { CoverURL string `json:"coverUrl"` RegionName string `json:"regionName"` RoomID string `json:"roomId"` RoomShortID string `json:"roomShortId"` Status string `json:"status"` Title string `json:"title"` VisibleRegionID int64 `json:"visibleRegionId"` } type RoomPin struct { CancelledAtMS int64 `json:"cancelledAtMs"` CancelledByAdminID uint `json:"cancelledByAdminId"` CreatedAtMS int64 `json:"createdAtMs"` CreatedByAdminID uint `json:"createdByAdminId"` ExpiresAtMS int64 `json:"expiresAtMs"` ID int64 `json:"id"` PinnedAtMS int64 `json:"pinnedAtMs"` RemainingMS int64 `json:"remainingMs"` Room RoomPinRoom `json:"room"` Status string `json:"status"` UpdatedAtMS int64 `json:"updatedAtMs"` User RoomOwner `json:"user"` Weight int64 `json:"weight"` } func (s *Service) ListRoomPins(ctx context.Context, query roomPinListQuery) ([]RoomPin, int64, error) { if s.db == nil { return nil, 0, fmt.Errorf("room mysql is not configured") } query = normalizeRoomPinListQuery(query) whereSQL, args := roomPinListWhere(ctx, query, time.Now().UTC().UnixMilli()) total, err := countRows(ctx, s.db, whereSQL, args...) if err != nil { return nil, 0, err } rows, err := s.db.QueryContext(ctx, ` SELECT p.id, p.visible_region_id, p.room_id, p.weight, p.status, p.pinned_at_ms, p.expires_at_ms, p.cancelled_at_ms, p.created_by_admin_id, p.cancelled_by_admin_id, p.created_at_ms, p.updated_at_ms, r.room_short_id, r.title, r.cover_url, r.owner_user_id, r.status `+whereSQL+` ORDER BY p.weight DESC, p.expires_at_ms DESC, p.id DESC LIMIT ? OFFSET ? `, append(args, query.PageSize, offset(query.Page, query.PageSize))...) if err != nil { return nil, 0, err } defer rows.Close() items := make([]RoomPin, 0, query.PageSize) for rows.Next() { item, err := scanRoomPin(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.fillRoomPinDetails(ctx, items); err != nil { return nil, 0, err } return items, total, nil } func (s *Service) CreateRoomPin(ctx context.Context, req createRoomPinRequest, actorID uint) (RoomPin, error) { if s.db == nil { return RoomPin{}, fmt.Errorf("room mysql is not configured") } input, err := normalizeCreateRoomPinRequest(req) if err != nil { return RoomPin{}, err } room, _, err := s.getRoomForPin(ctx, input.RoomID) if err != nil { return RoomPin{}, err } if room.Status != "active" { return RoomPin{}, fmt.Errorf("%w: 只能置顶正常状态房间", ErrInvalidArgument) } nowMS := time.Now().UTC().UnixMilli() expiresAtMS := nowMS + input.DurationDays*roomPinDayMillis appCode := appctx.FromContext(ctx) if _, err := s.db.ExecContext(ctx, ` INSERT INTO room_region_pins ( app_code, visible_region_id, room_id, weight, status, pinned_at_ms, expires_at_ms, cancelled_at_ms, created_by_admin_id, cancelled_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?) ON DUPLICATE KEY UPDATE weight = VALUES(weight), status = VALUES(status), pinned_at_ms = VALUES(pinned_at_ms), expires_at_ms = VALUES(expires_at_ms), cancelled_at_ms = 0, created_by_admin_id = VALUES(created_by_admin_id), cancelled_by_admin_id = 0, created_at_ms = VALUES(created_at_ms), updated_at_ms = VALUES(updated_at_ms) `, appCode, room.VisibleRegionID, room.RoomID, input.Weight, roomPinStatusActive, nowMS, expiresAtMS, actorID, nowMS, nowMS); err != nil { return RoomPin{}, err } pin, err := s.getRoomPinByRoom(ctx, room.VisibleRegionID, room.RoomID) if err != nil { return RoomPin{}, err } return pin, nil } func (s *Service) CancelRoomPin(ctx context.Context, pinID int64, actorID uint) (RoomPin, error) { if s.db == nil { return RoomPin{}, fmt.Errorf("room mysql is not configured") } if pinID <= 0 { return RoomPin{}, fmt.Errorf("%w: 置顶 ID 不正确", ErrInvalidArgument) } nowMS := time.Now().UTC().UnixMilli() appCode := appctx.FromContext(ctx) result, err := s.db.ExecContext(ctx, ` UPDATE room_region_pins SET status = ?, cancelled_at_ms = ?, cancelled_by_admin_id = ?, updated_at_ms = ? WHERE app_code = ? AND id = ? AND status <> ? `, roomPinStatusCancelled, nowMS, actorID, nowMS, appCode, pinID, roomPinStatusCancelled) if err != nil { return RoomPin{}, err } if affected, err := result.RowsAffected(); err == nil && affected == 0 { if _, err := s.getRoomPinByID(ctx, pinID); errors.Is(err, ErrNotFound) { return RoomPin{}, ErrNotFound } } return s.getRoomPinByID(ctx, pinID) } func (s *Service) getRoomForPin(ctx context.Context, roomID string) (RoomPinRoom, int64, error) { appCode := appctx.FromContext(ctx) row := s.db.QueryRowContext(ctx, ` SELECT room_id, room_short_id, visible_region_id, owner_user_id, title, cover_url, status FROM room_list_entries WHERE app_code = ? AND (room_id = ? OR room_short_id = ?) LIMIT 1 `, appCode, roomID, roomID) var room RoomPinRoom var ownerUserID int64 if err := row.Scan(&room.RoomID, &room.RoomShortID, &room.VisibleRegionID, &ownerUserID, &room.Title, &room.CoverURL, &room.Status); err != nil { if errors.Is(err, sql.ErrNoRows) { return RoomPinRoom{}, 0, ErrNotFound } return RoomPinRoom{}, 0, err } if room.Status != "active" { room.Status = "closed" } return room, ownerUserID, nil } func (s *Service) getRoomPinByRoom(ctx context.Context, regionID int64, roomID string) (RoomPin, error) { return s.getRoomPin(ctx, ` WHERE p.app_code = ? AND p.visible_region_id = ? AND p.room_id = ? `, appctx.FromContext(ctx), regionID, roomID) } func (s *Service) getRoomPinByID(ctx context.Context, pinID int64) (RoomPin, error) { return s.getRoomPin(ctx, ` WHERE p.app_code = ? AND p.id = ? `, appctx.FromContext(ctx), pinID) } func (s *Service) getRoomPin(ctx context.Context, whereSQL string, args ...any) (RoomPin, error) { row := s.db.QueryRowContext(ctx, ` SELECT p.id, p.visible_region_id, p.room_id, p.weight, p.status, p.pinned_at_ms, p.expires_at_ms, p.cancelled_at_ms, p.created_by_admin_id, p.cancelled_by_admin_id, p.created_at_ms, p.updated_at_ms, r.room_short_id, r.title, r.cover_url, r.owner_user_id, r.status FROM room_region_pins p JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id `+whereSQL+` LIMIT 1 `, args...) item, err := scanRoomPin(row) if errors.Is(err, sql.ErrNoRows) { return RoomPin{}, ErrNotFound } if err != nil { return RoomPin{}, err } items := []RoomPin{item} if err := s.fillRoomPinDetails(ctx, items); err != nil { return RoomPin{}, err } return items[0], nil } func roomPinListWhere(ctx context.Context, query roomPinListQuery, nowMS int64) (string, []any) { where := []string{"p.app_code = ?"} args := []any{appctx.FromContext(ctx)} switch query.Status { case roomPinStatusActive: where = append(where, "p.status = ?", "p.expires_at_ms > ?") args = append(args, roomPinStatusActive, nowMS) case roomPinStatusExpired: where = append(where, "p.status = ?", "p.expires_at_ms <= ?") args = append(args, roomPinStatusActive, nowMS) case roomPinStatusCancelled: where = append(where, "p.status = ?") args = append(args, roomPinStatusCancelled) } if query.RegionID > 0 { where = append(where, "p.visible_region_id = ?") args = append(args, query.RegionID) } if query.Keyword != "" { like := "%" + query.Keyword + "%" where = append(where, "(p.room_id LIKE ? OR r.room_short_id LIKE ? OR r.title LIKE ? OR CAST(r.owner_user_id AS CHAR) LIKE ?)") args = append(args, like, like, like, like) } return ` FROM room_region_pins p JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id WHERE ` + strings.Join(where, " AND "), args } func normalizeRoomPinListQuery(query roomPinListQuery) roomPinListQuery { 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 = normalizeRoomPinStatus(query.Status) if query.RegionID < 0 { query.RegionID = 0 } return query } func normalizeRoomPinStatus(status string) string { switch strings.ToLower(strings.TrimSpace(status)) { case "", roomPinStatusActive: return roomPinStatusActive case roomPinStatusExpired: return roomPinStatusExpired case roomPinStatusCancelled: return roomPinStatusCancelled case roomPinStatusAll: return roomPinStatusAll default: return roomPinStatusActive } } func normalizeCreateRoomPinRequest(req createRoomPinRequest) (createRoomPinRequest, error) { req.RoomID = strings.TrimSpace(req.RoomID) if req.RoomID == "" || len(req.RoomID) > 64 { return req, fmt.Errorf("%w: 请选择房间", ErrInvalidArgument) } if req.Weight < 0 { return req, fmt.Errorf("%w: 权重不能小于 0", ErrInvalidArgument) } if req.DurationDays == 0 { req.DurationDays = defaultRoomPinDays } if req.DurationDays < 0 || req.DurationDays > maxRoomPinDays { return req, fmt.Errorf("%w: 置顶时间必须在 1 到 3650 天之间", ErrInvalidArgument) } return req, nil } type roomPinScanner interface { Scan(dest ...any) error } func scanRoomPin(row roomPinScanner) (RoomPin, error) { var item RoomPin var ownerUserID int64 err := row.Scan( &item.ID, &item.Room.VisibleRegionID, &item.Room.RoomID, &item.Weight, &item.Status, &item.PinnedAtMS, &item.ExpiresAtMS, &item.CancelledAtMS, &item.CreatedByAdminID, &item.CancelledByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.Room.RoomShortID, &item.Room.Title, &item.Room.CoverURL, &ownerUserID, &item.Room.Status, ) item.User = RoomOwner{UserID: strconv.FormatInt(ownerUserID, 10)} item.RemainingMS = item.ExpiresAtMS - time.Now().UTC().UnixMilli() if item.RemainingMS < 0 { item.RemainingMS = 0 } if item.Room.Status != "active" { item.Room.Status = "closed" } return item, err } func (s *Service) fillRoomPinDetails(ctx context.Context, items []RoomPin) 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.User.UserID, 10, 64); err == nil && ownerID > 0 { ownerIDs = append(ownerIDs, ownerID) } if item.Room.VisibleRegionID > 0 { regionIDs = append(regionIDs, item.Room.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].User.UserID, 10, 64); err == nil { if owner, ok := owners[ownerID]; ok { items[index].User = owner } } if name, ok := regions[items[index].Room.VisibleRegionID]; ok { items[index].Room.RegionName = name } } return nil }