576 lines
17 KiB
Go
576 lines
17 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"
|
||
"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 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
|
||
}
|
||
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.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(®ionID, &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
|
||
}
|