2026-06-29 14:26:06 +08:00

583 lines
17 KiB
Go
Raw Permalink 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 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"
"hyapp-admin-server/internal/repository"
)
var (
ErrInvalidArgument = errors.New("invalid argument")
ErrNotFound = errors.New("not found")
)
type Service struct {
userDB *sql.DB
store *repository.Store
roomClient roomclient.Client
robotClient robotclient.Client
}
type RoomOwner struct {
Avatar string `json:"avatar"`
CountryCode string `json:"countryCode,omitempty"`
DisplayUserID string `json:"displayUserId"`
UserID string `json:"userId"`
Username string `json:"username"`
VisibleRegionID int64 `json:"visibleRegionId,omitempty"`
}
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, store *repository.Store, roomClient roomclient.Client, robotClient robotclient.Client) *Service {
return &Service{userDB: userDB, store: store, 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)
ownerUserID, ownerMatched, err := s.resolveListOwnerUserID(ctx, query)
if err != nil {
return nil, 0, err
}
if (!query.OwnerFilter.IsEmpty() || strings.TrimSpace(query.OwnerUserKeyword) != "") && !ownerMatched {
return []Room{}, 0, nil
}
roomKeyword := query.Keyword
if ownerUserID == 0 {
keywordOwnerUserID, keywordOwnerMatched, err := s.resolveOwnerNumberUserID(ctx, query.Keyword)
if err != nil {
return nil, 0, err
}
if keywordOwnerMatched {
ownerUserID = keywordOwnerUserID
roomKeyword = ""
}
}
result, err := s.roomClient.ListRooms(ctx, roomclient.ListRoomsRequest{
Page: query.Page,
PageSize: query.PageSize,
Keyword: roomKeyword,
Status: query.Status,
RegionID: query.RegionID,
OwnerUserID: ownerUserID,
SortBy: query.SortBy,
SortDirection: query.SortDirection,
})
if err != nil {
return nil, 0, err
}
items := roomsFromClient(result.Rooms)
if err := s.fillRoomDetails(ctx, items); err != nil {
return nil, 0, err
}
return items, result.Total, nil
}
func (s *Service) resolveListOwnerUserID(ctx context.Context, query listQuery) (int64, bool, error) {
if query.OwnerUserID > 0 {
return query.OwnerUserID, true, nil
}
if !query.OwnerFilter.IsEmpty() {
return shared.ResolveUserIdentityFilter(ctx, s.userDB, appctx.FromContext(ctx), query.OwnerFilter, time.Now().UnixMilli())
}
return s.resolveOwnerUserID(ctx, query.OwnerUserKeyword)
}
func (s *Service) resolveOwnerUserID(ctx context.Context, keyword string) (int64, bool, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return 0, false, nil
}
if s.userDB == nil {
return shared.ResolveExactUserIDOrNumericFallback(ctx, nil, appctx.FromContext(ctx), keyword, time.Now().UnixMilli())
}
// 房主筛选必须先把短号、当前展示号、active 靓号等运营可见号码解析成稳定 user_id再交给 room-service 按 owner_user_id 精确过滤。
return shared.ResolveExactUserID(ctx, s.userDB, appctx.FromContext(ctx), keyword, time.Now().UnixMilli())
}
func (s *Service) resolveOwnerNumberUserID(ctx context.Context, keyword string) (int64, bool, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" || s.userDB == nil {
return 0, false, nil
}
nowMS := time.Now().UnixMilli()
displaySQL, displayArgs := shared.UserDisplayIDSQL("u", keyword, nowMS)
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMS)
args := append([]any{appctx.FromContext(ctx), keyword}, displayArgs...)
args = append(args, orderArgs...)
row := s.userDB.QueryRowContext(ctx, `
SELECT u.user_id
FROM users u
WHERE u.app_code = ? AND (CAST(u.user_id AS CHAR) = ? OR `+displaySQL+`)
ORDER BY
`+orderSQL+`,
u.user_id DESC
LIMIT 1`,
args...,
)
var userID int64
if err := row.Scan(&userID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, false, 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.OwnerUserKeyword = strings.TrimSpace(query.OwnerUserKeyword)
query.OwnerFilter.UserID = strings.TrimSpace(query.OwnerFilter.UserID)
query.OwnerFilter.DisplayUserID = strings.TrimSpace(query.OwnerFilter.DisplayUserID)
query.OwnerFilter.Username = strings.TrimSpace(query.OwnerFilter.Username)
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, ''),
COALESCE(country, ''),
COALESCE(region_id, 0)
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, &owner.CountryCode, &owner.VisibleRegionID); 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
}