2026-06-15 23:53:30 +08:00

549 lines
16 KiB
Go

package roomadmin
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/robotclient"
"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
robotClient robotclient.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, robotClient robotclient.Client) *Service {
return &Service{userDB: userDB, roomClient: roomClient, robotClient: robotClient}
}
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
}
if result.Total == 0 && strings.TrimSpace(query.Keyword) != "" && s.userDB != nil {
ownerUserID, ok, err := s.queryOwnerUserIDByDisplayID(ctx, query.Keyword)
if err != nil {
return nil, 0, err
}
if ok {
// 新增置顶弹窗按用户短 ID 找房间;精确解析到 user_id 后交给 room-service owner 过滤,避免字符串 LIKE 误命中。
fallback, err := s.roomClient.ListRooms(ctx, roomclient.ListRoomsRequest{
Page: query.Page,
PageSize: query.PageSize,
Status: query.Status,
RegionID: query.RegionID,
OwnerUserID: ownerUserID,
SortBy: query.SortBy,
SortDirection: query.SortDirection,
})
if err != nil {
return nil, 0, err
}
if fallback.Total > 0 {
result = fallback
}
}
}
items := roomsFromClient(result.Rooms)
if err := s.fillRoomDetails(ctx, items); err != nil {
return nil, 0, err
}
return items, result.Total, nil
}
func (s *Service) queryOwnerUserIDByDisplayID(ctx context.Context, displayUserID string) (int64, bool, error) {
displayUserID = strings.TrimSpace(displayUserID)
if displayUserID == "" || s.userDB == nil {
return 0, false, nil
}
matchSQL, matchArgs := shared.UserDisplayIDSQL("u", displayUserID, time.Now().UnixMilli())
args := append([]any{appctx.FromContext(ctx)}, matchArgs...)
var userID int64
err := s.userDB.QueryRowContext(ctx, `
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND `+matchSQL+`
LIMIT 1
`, args...).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return userID, true, 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(&regionID, &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
}