620 lines
16 KiB
Go
620 lines
16 KiB
Go
package roomadmin
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
"hyapp-admin-server/internal/appctx"
|
|
roomv1 "hyapp.local/api/proto/room/v1"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidArgument = errors.New("invalid argument")
|
|
ErrNotFound = errors.New("not found")
|
|
)
|
|
|
|
type Service struct {
|
|
db *sql.DB
|
|
userDB *sql.DB
|
|
}
|
|
|
|
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"`
|
|
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"`
|
|
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(db *sql.DB, userDB *sql.DB) *Service {
|
|
return &Service{db: db, userDB: userDB}
|
|
}
|
|
|
|
func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64, error) {
|
|
if s.db == nil {
|
|
return nil, 0, fmt.Errorf("room mysql is not configured")
|
|
}
|
|
query = normalizeListQuery(query)
|
|
appCode := appctx.FromContext(ctx)
|
|
whereSQL := "FROM room_list_entries rle WHERE rle.app_code = ?"
|
|
args := []any{appCode}
|
|
if query.Status == "closed" {
|
|
whereSQL += " AND rle.status <> ?"
|
|
args = append(args, "active")
|
|
} else if query.Status != "" {
|
|
whereSQL += " AND rle.status = ?"
|
|
args = append(args, query.Status)
|
|
}
|
|
if query.RegionID > 0 {
|
|
whereSQL += " AND rle.visible_region_id = ?"
|
|
args = append(args, query.RegionID)
|
|
}
|
|
if query.Keyword != "" {
|
|
like := "%" + query.Keyword + "%"
|
|
whereSQL += " AND (rle.room_id LIKE ? OR rle.title LIKE ? OR CAST(rle.owner_user_id AS CHAR) LIKE ?)"
|
|
args = append(args, like, like, like)
|
|
}
|
|
total, err := countRows(ctx, s.db, whereSQL, args...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`
|
|
SELECT rle.room_id,
|
|
rle.visible_region_id,
|
|
rle.owner_user_id,
|
|
rle.title,
|
|
rle.cover_url,
|
|
rle.mode,
|
|
rle.status,
|
|
rle.heat,
|
|
rle.online_count,
|
|
rle.seat_count,
|
|
rle.occupied_seat_count,
|
|
rle.sort_score,
|
|
rle.created_at_ms,
|
|
rle.updated_at_ms
|
|
%s
|
|
ORDER BY %s
|
|
LIMIT ? OFFSET ?
|
|
`, whereSQL, roomListOrderBy(query)), append(args, query.PageSize, offset(query.Page, query.PageSize))...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
items := make([]Room, 0, query.PageSize)
|
|
for rows.Next() {
|
|
item, err := scanRoom(rows)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if err := s.fillRoomDetails(ctx, items); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
func (s *Service) GetRoom(ctx context.Context, roomID string) (Room, error) {
|
|
if s.db == nil {
|
|
return Room{}, fmt.Errorf("room mysql is not configured")
|
|
}
|
|
appCode := appctx.FromContext(ctx)
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT room_id,
|
|
visible_region_id,
|
|
owner_user_id,
|
|
title,
|
|
cover_url,
|
|
mode,
|
|
status,
|
|
heat,
|
|
online_count,
|
|
seat_count,
|
|
occupied_seat_count,
|
|
sort_score,
|
|
created_at_ms,
|
|
updated_at_ms
|
|
FROM room_list_entries
|
|
WHERE app_code = ? AND room_id = ?
|
|
`, appCode, roomID)
|
|
item, err := scanRoom(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Room{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Room{}, err
|
|
}
|
|
items := []Room{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) (Room, error) {
|
|
if s.db == nil {
|
|
return Room{}, fmt.Errorf("room mysql is not configured")
|
|
}
|
|
updates, snapshotPatch, err := buildRoomUpdate(req)
|
|
if err != nil {
|
|
return Room{}, err
|
|
}
|
|
if len(updates) == 0 {
|
|
return s.GetRoom(ctx, roomID)
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return Room{}, err
|
|
}
|
|
defer rollbackIfOpen(tx)
|
|
|
|
var currentRoomID string
|
|
appCode := appctx.FromContext(ctx)
|
|
if err := tx.QueryRowContext(ctx, `SELECT room_id FROM room_list_entries WHERE app_code = ? AND room_id = ? FOR UPDATE`, appCode, roomID).Scan(¤tRoomID); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Room{}, ErrNotFound
|
|
}
|
|
return Room{}, err
|
|
}
|
|
|
|
now := time.Now().UnixMilli()
|
|
listSets := make([]string, 0, len(updates)+1)
|
|
listArgs := make([]any, 0, len(updates)+2)
|
|
roomSets := make([]string, 0, 3)
|
|
roomArgs := make([]any, 0, 4)
|
|
for _, update := range updates {
|
|
if update.listColumn != "" {
|
|
listSets = append(listSets, update.listColumn+" = ?")
|
|
listArgs = append(listArgs, update.value)
|
|
}
|
|
if update.roomColumn != "" {
|
|
roomSets = append(roomSets, update.roomColumn+" = ?")
|
|
roomArgs = append(roomArgs, update.value)
|
|
}
|
|
}
|
|
listSets = append(listSets, "updated_at_ms = ?")
|
|
listArgs = append(listArgs, now, appCode, roomID)
|
|
if _, err := tx.ExecContext(ctx, "UPDATE room_list_entries SET "+strings.Join(listSets, ", ")+" WHERE app_code = ? AND room_id = ?", listArgs...); err != nil {
|
|
return Room{}, err
|
|
}
|
|
if len(roomSets) > 0 {
|
|
roomArgs = append(roomArgs, appCode, roomID)
|
|
if _, err := tx.ExecContext(ctx, "UPDATE rooms SET "+strings.Join(roomSets, ", ")+" WHERE app_code = ? AND room_id = ?", roomArgs...); err != nil {
|
|
return Room{}, err
|
|
}
|
|
}
|
|
if err := updateSnapshot(ctx, tx, roomID, snapshotPatch); err != nil {
|
|
return Room{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return Room{}, err
|
|
}
|
|
return s.GetRoom(ctx, roomID)
|
|
}
|
|
|
|
func (s *Service) DeleteRoom(ctx context.Context, roomID string) error {
|
|
if s.db == nil {
|
|
return fmt.Errorf("room mysql is not configured")
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rollbackIfOpen(tx)
|
|
|
|
appCode := appctx.FromContext(ctx)
|
|
result, err := tx.ExecContext(ctx, `DELETE FROM room_list_entries WHERE app_code = ? AND room_id = ?`, appCode, roomID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affected, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if affected == 0 {
|
|
return ErrNotFound
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`, appCode, roomID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE app_code = ? AND room_id = ?`, appCode, roomID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
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)
|
|
}
|
|
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 updateSnapshot(ctx context.Context, tx *sql.Tx, roomID string, patch snapshotPatch) error {
|
|
var payload []byte
|
|
appCode := appctx.FromContext(ctx)
|
|
err := tx.QueryRowContext(ctx, `SELECT payload FROM room_snapshots WHERE app_code = ? AND room_id = ? FOR UPDATE`, appCode, roomID).Scan(&payload)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
snapshot := &roomv1.RoomSnapshot{}
|
|
if err := proto.Unmarshal(payload, snapshot); err != nil {
|
|
return err
|
|
}
|
|
if snapshot.RoomExt == nil {
|
|
snapshot.RoomExt = map[string]string{}
|
|
}
|
|
if patch.titleSet {
|
|
snapshot.RoomExt["title"] = patch.title
|
|
}
|
|
if patch.coverURLSet {
|
|
snapshot.RoomExt["cover_url"] = patch.coverURL
|
|
}
|
|
if patch.descriptionSet {
|
|
snapshot.RoomExt["description"] = patch.description
|
|
}
|
|
if patch.modeSet {
|
|
snapshot.Mode = patch.mode
|
|
}
|
|
if patch.statusSet {
|
|
snapshot.Status = patch.status
|
|
}
|
|
nextPayload, err := proto.Marshal(snapshot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.ExecContext(ctx, `UPDATE room_snapshots SET payload = ? WHERE app_code = ? AND room_id = ?`, nextPayload, appCode, roomID)
|
|
return err
|
|
}
|
|
|
|
type roomScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanRoom(row roomScanner) (Room, error) {
|
|
var item Room
|
|
var ownerUserID int64
|
|
err := row.Scan(
|
|
&item.RoomID,
|
|
&item.VisibleRegionID,
|
|
&ownerUserID,
|
|
&item.Title,
|
|
&item.CoverURL,
|
|
&item.Mode,
|
|
&item.Status,
|
|
&item.Heat,
|
|
&item.OnlineCount,
|
|
&item.SeatCount,
|
|
&item.OccupiedSeatCount,
|
|
&item.SortScore,
|
|
&item.CreatedAtMs,
|
|
&item.UpdatedAtMs,
|
|
)
|
|
item.OwnerUserID = strconv.FormatInt(ownerUserID, 10)
|
|
item.Owner = RoomOwner{UserID: item.OwnerUserID}
|
|
item.RoomContribution = item.Heat
|
|
if item.Status != "active" {
|
|
item.Status = "closed"
|
|
}
|
|
return item, err
|
|
}
|
|
|
|
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 (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(®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 countRows(ctx context.Context, db *sql.DB, whereSQL string, args ...any) (int64, error) {
|
|
var total int64
|
|
err := db.QueryRowContext(ctx, "SELECT COUNT(*) "+whereSQL, args...).Scan(&total)
|
|
return total, err
|
|
}
|
|
|
|
func offset(page int, pageSize int) int {
|
|
return (page - 1) * pageSize
|
|
}
|
|
|
|
func rollbackIfOpen(tx *sql.Tx) {
|
|
_ = tx.Rollback()
|
|
}
|