package service import ( "context" "errors" "log/slog" "strings" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/pkg/xerr" ) var adminSeatCandidates = []int32{10, 15, 20, 25, 30} const ( roomPinTypeRegion = "region" roomPinTypeGlobal = "global" roomPinTypeCountry = "country" roomPinDayMillis = int64(24 * 60 * 60 * 1000) ) func (s *Service) AdminGetRoomSeatConfig(ctx context.Context, _ *roomv1.AdminGetRoomSeatConfigRequest) (*roomv1.AdminGetRoomSeatConfigResponse, error) { config, err := s.currentRoomSeatConfig(ctx) if err != nil { return nil, err } return &roomv1.AdminGetRoomSeatConfigResponse{Config: seatConfigToAdminProto(config), ServerTimeMs: s.clock.Now().UnixMilli()}, nil } func (s *Service) AdminUpdateRoomSeatConfig(ctx context.Context, req *roomv1.AdminUpdateRoomSeatConfigRequest) (*roomv1.AdminUpdateRoomSeatConfigResponse, error) { if req == nil { return nil, xerr.New(xerr.InvalidArgument, "room seat config is required") } config, err := normalizeRoomSeatConfig(RoomSeatConfig{ AppCode: appcode.FromContext(ctx), AllowedSeatCounts: req.GetAllowedSeatCounts(), DefaultSeatCount: req.GetDefaultSeatCount(), UpdatedAtMS: s.clock.Now().UTC().UnixMilli(), }) if err != nil { return nil, xerr.New(xerr.InvalidArgument, err.Error()) } if err := s.repository.UpsertRoomSeatConfig(ctx, config); err != nil { return nil, err } return &roomv1.AdminUpdateRoomSeatConfigResponse{Config: seatConfigToAdminProto(config), ServerTimeMs: config.UpdatedAtMS}, nil } func (s *Service) AdminListRoomPins(ctx context.Context, req *roomv1.AdminListRoomPinsRequest) (*roomv1.AdminListRoomPinsResponse, error) { nowMS := s.clock.Now().UTC().UnixMilli() pins, total, err := s.repository.AdminListRoomPins(ctx, AdminRoomPinQuery{ Page: int(req.GetPage()), PageSize: int(req.GetPageSize()), Keyword: strings.TrimSpace(req.GetKeyword()), Status: strings.TrimSpace(req.GetStatus()), RegionID: req.GetVisibleRegionId(), PinType: normalizeRoomPinListType(req.GetPinType()), NowMS: nowMS, }) if err != nil { // gRPC 传输层会把普通 repository error 统一隐藏成 INTERNAL_ERROR;这里保留原始错误和筛选边界, // 让线上排障能直接区分 SQL/schema 问题、上下文租户问题和输入条件问题。 logx.Error(ctx, "admin_list_room_pins_failed", err, slog.Int("page", int(req.GetPage())), slog.Int("page_size", int(req.GetPageSize())), slog.String("status", strings.TrimSpace(req.GetStatus())), slog.Int64("visible_region_id", req.GetVisibleRegionId()), slog.String("pin_type", normalizeRoomPinListType(req.GetPinType())), slog.Bool("has_keyword", strings.TrimSpace(req.GetKeyword()) != ""), ) return nil, err } out := make([]*roomv1.AdminRoomPin, 0, len(pins)) for _, pin := range pins { if pin.Status == "active" && pin.ExpiresAtMS <= nowMS { // 过期不需要回写房间状态;对后台读模型呈现 expired,公共列表查询也会自动把它排除出置顶 join。 pin.Status = "expired" } out = append(out, roomPinToAdminProto(pin)) } return &roomv1.AdminListRoomPinsResponse{Pins: out, Total: total, ServerTimeMs: nowMS}, nil } func (s *Service) AdminCreateRoomPin(ctx context.Context, req *roomv1.AdminCreateRoomPinRequest) (*roomv1.AdminCreateRoomPinResponse, error) { if req == nil || strings.TrimSpace(req.GetRoomId()) == "" { return nil, xerr.New(xerr.InvalidArgument, "room id is required") } if req.GetWeight() < 0 { return nil, xerr.New(xerr.InvalidArgument, "room pin weight is invalid") } nowMS := s.clock.Now().UTC().UnixMilli() pinnedAtMS, expiresAtMS, err := normalizeAdminRoomPinInterval(req.GetPinnedAtMs(), req.GetExpiresAtMs(), req.GetDurationDays(), nowMS) if err != nil { return nil, xerr.New(xerr.InvalidArgument, err.Error()) } pin, ok, err := s.repository.AdminCreateRoomPin(ctx, AdminCreateRoomPinInput{ RoomID: strings.TrimSpace(req.GetRoomId()), PinType: normalizeRoomPinType(req.GetPinType()), Weight: req.GetWeight(), DurationDays: req.GetDurationDays(), PinnedAtMS: pinnedAtMS, ExpiresAtMS: expiresAtMS, AdminID: req.GetAdminId(), NowMS: nowMS, }) if err != nil { return nil, err } if !ok { return nil, xerr.New(xerr.Conflict, "room is not active or not found") } s.projectRoomPinCacheBestEffort(ctx, roomListPinEntryFromAdmin(pin)) return &roomv1.AdminCreateRoomPinResponse{Pin: roomPinToAdminProto(pin), ServerTimeMs: nowMS}, nil } func (s *Service) AdminCancelRoomPin(ctx context.Context, req *roomv1.AdminCancelRoomPinRequest) (*roomv1.AdminCancelRoomPinResponse, error) { if req == nil || req.GetPinId() <= 0 { return nil, xerr.New(xerr.InvalidArgument, "room pin id is invalid") } nowMS := s.clock.Now().UTC().UnixMilli() pin, ok, err := s.repository.AdminCancelRoomPin(ctx, req.GetPinId(), req.GetAdminId(), nowMS) if err != nil { return nil, err } if !ok { return nil, xerr.New(xerr.NotFound, "room pin not found") } s.projectRoomPinCacheBestEffort(ctx, roomListPinEntryFromAdmin(pin)) return &roomv1.AdminCancelRoomPinResponse{Pin: roomPinToAdminProto(pin), ServerTimeMs: nowMS}, nil } func (s *Service) projectRoomPinCacheBestEffort(ctx context.Context, pin RoomListPinEntry) { if s == nil || s.roomListCache == nil { return } if err := s.roomListCache.UpsertRoomPin(ctx, pin); err != nil { // 后台置顶 owner 已经写入 MySQL;缓存失败只影响短暂展示顺序,后续 refresh 会从 owner 表重建。 logx.Warn(ctx, "room_pin_cache_project_failed", slog.Int64("pin_id", pin.ID), slog.String("room_id", pin.RoomID), slog.String("pin_type", pin.PinType), slog.String("error", err.Error())) } } func roomListPinEntryFromAdmin(pin AdminRoomPinEntry) RoomListPinEntry { return RoomListPinEntry{ ID: pin.ID, AppCode: pin.AppCode, VisibleRegionID: pin.VisibleRegionID, PinType: pin.PinType, RoomID: pin.RoomID, Weight: pin.Weight, Status: pin.Status, PinnedAtMS: pin.PinnedAtMS, ExpiresAtMS: pin.ExpiresAtMS, } } func seatConfigToAdminProto(config RoomSeatConfig) *roomv1.AdminRoomSeatConfig { return &roomv1.AdminRoomSeatConfig{ CandidateSeatCounts: append([]int32(nil), adminSeatCandidates...), AllowedSeatCounts: append([]int32(nil), config.AllowedSeatCounts...), DefaultSeatCount: config.DefaultSeatCount, UpdatedAtMs: config.UpdatedAtMS, } } func roomPinToAdminProto(pin AdminRoomPinEntry) *roomv1.AdminRoomPin { return &roomv1.AdminRoomPin{ Id: pin.ID, VisibleRegionId: pin.VisibleRegionID, RoomId: pin.RoomID, Weight: pin.Weight, Status: pin.Status, PinType: pin.PinType, PinnedAtMs: pin.PinnedAtMS, ExpiresAtMs: pin.ExpiresAtMS, CancelledAtMs: pin.CancelledAtMS, CreatedByAdminId: pin.CreatedByAdminID, CancelledByAdminId: pin.CancelledByAdminID, CreatedAtMs: pin.CreatedAtMS, UpdatedAtMs: pin.UpdatedAtMS, Room: &roomv1.AdminRoomPinRoom{ RoomId: pin.RoomID, RoomShortId: pin.RoomShortID, VisibleRegionId: pin.RoomVisibleRegionID, OwnerUserId: pin.OwnerUserID, Title: pin.Title, CoverUrl: pin.CoverURL, Status: pin.RoomStatus, }, } } func normalizeRoomPinType(pinType string) string { switch strings.ToLower(strings.TrimSpace(pinType)) { case roomPinTypeGlobal: return roomPinTypeGlobal case roomPinTypeCountry: return roomPinTypeCountry default: // 空值和未知值都回落到区域置顶,兼容旧后台只传 room_id/duration_days 的请求。 return roomPinTypeRegion } } func normalizeRoomPinListType(pinType string) string { switch strings.ToLower(strings.TrimSpace(pinType)) { case roomPinTypeRegion: return roomPinTypeRegion case roomPinTypeGlobal: return roomPinTypeGlobal case roomPinTypeCountry: return roomPinTypeCountry default: // 列表空类型代表“不按置顶类型筛选”;不能复用创建默认 region,否则国家置顶会从后台总列表消失。 return "" } } func normalizeAdminRoomPinInterval(pinnedAtMS int64, expiresAtMS int64, durationDays int64, nowMS int64) (int64, int64, error) { if pinnedAtMS <= 0 { pinnedAtMS = nowMS } if expiresAtMS <= 0 && durationDays > 0 { expiresAtMS = pinnedAtMS + durationDays*roomPinDayMillis } if expiresAtMS <= pinnedAtMS { return 0, 0, errors.New("room pin time range is invalid") } if expiresAtMS-pinnedAtMS > 3650*roomPinDayMillis { return 0, 0, errors.New("room pin time range is too long") } return pinnedAtMS, expiresAtMS, nil }