2026-06-26 21:31:14 +08:00

235 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}