2026-05-14 00:25:02 +08:00

410 lines
12 KiB
Go

package roomadmin
import (
"context"
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
)
const (
roomPinStatusActive = "active"
roomPinStatusCancelled = "cancelled"
roomPinStatusExpired = "expired"
roomPinStatusAll = "all"
defaultRoomPinDays = int64(30)
maxRoomPinDays = int64(3650)
roomPinDayMillis = int64(24 * 60 * 60 * 1000)
)
type RoomPinRoom struct {
CoverURL string `json:"coverUrl"`
RegionName string `json:"regionName"`
RoomID string `json:"roomId"`
RoomShortID string `json:"roomShortId"`
Status string `json:"status"`
Title string `json:"title"`
VisibleRegionID int64 `json:"visibleRegionId"`
}
type RoomPin struct {
CancelledAtMS int64 `json:"cancelledAtMs"`
CancelledByAdminID uint `json:"cancelledByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
CreatedByAdminID uint `json:"createdByAdminId"`
ExpiresAtMS int64 `json:"expiresAtMs"`
ID int64 `json:"id"`
PinnedAtMS int64 `json:"pinnedAtMs"`
RemainingMS int64 `json:"remainingMs"`
Room RoomPinRoom `json:"room"`
Status string `json:"status"`
UpdatedAtMS int64 `json:"updatedAtMs"`
User RoomOwner `json:"user"`
Weight int64 `json:"weight"`
}
func (s *Service) ListRoomPins(ctx context.Context, query roomPinListQuery) ([]RoomPin, int64, error) {
if s.db == nil {
return nil, 0, fmt.Errorf("room mysql is not configured")
}
query = normalizeRoomPinListQuery(query)
whereSQL, args := roomPinListWhere(ctx, query, time.Now().UTC().UnixMilli())
total, err := countRows(ctx, s.db, whereSQL, args...)
if err != nil {
return nil, 0, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT p.id,
p.visible_region_id,
p.room_id,
p.weight,
p.status,
p.pinned_at_ms,
p.expires_at_ms,
p.cancelled_at_ms,
p.created_by_admin_id,
p.cancelled_by_admin_id,
p.created_at_ms,
p.updated_at_ms,
r.room_short_id,
r.title,
r.cover_url,
r.owner_user_id,
r.status
`+whereSQL+`
ORDER BY p.weight DESC, p.expires_at_ms DESC, p.id DESC
LIMIT ? OFFSET ?
`, append(args, query.PageSize, offset(query.Page, query.PageSize))...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]RoomPin, 0, query.PageSize)
for rows.Next() {
item, err := scanRoomPin(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.fillRoomPinDetails(ctx, items); err != nil {
return nil, 0, err
}
return items, total, nil
}
func (s *Service) CreateRoomPin(ctx context.Context, req createRoomPinRequest, actorID uint) (RoomPin, error) {
if s.db == nil {
return RoomPin{}, fmt.Errorf("room mysql is not configured")
}
input, err := normalizeCreateRoomPinRequest(req)
if err != nil {
return RoomPin{}, err
}
room, _, err := s.getRoomForPin(ctx, input.RoomID)
if err != nil {
return RoomPin{}, err
}
if room.Status != "active" {
return RoomPin{}, fmt.Errorf("%w: 只能置顶正常状态房间", ErrInvalidArgument)
}
nowMS := time.Now().UTC().UnixMilli()
expiresAtMS := nowMS + input.DurationDays*roomPinDayMillis
appCode := appctx.FromContext(ctx)
if _, err := s.db.ExecContext(ctx, `
INSERT INTO room_region_pins (
app_code, visible_region_id, room_id, weight, status, pinned_at_ms, expires_at_ms,
cancelled_at_ms, created_by_admin_id, cancelled_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE
weight = VALUES(weight),
status = VALUES(status),
pinned_at_ms = VALUES(pinned_at_ms),
expires_at_ms = VALUES(expires_at_ms),
cancelled_at_ms = 0,
created_by_admin_id = VALUES(created_by_admin_id),
cancelled_by_admin_id = 0,
created_at_ms = VALUES(created_at_ms),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, room.VisibleRegionID, room.RoomID, input.Weight, roomPinStatusActive, nowMS, expiresAtMS, actorID, nowMS, nowMS); err != nil {
return RoomPin{}, err
}
pin, err := s.getRoomPinByRoom(ctx, room.VisibleRegionID, room.RoomID)
if err != nil {
return RoomPin{}, err
}
return pin, nil
}
func (s *Service) CancelRoomPin(ctx context.Context, pinID int64, actorID uint) (RoomPin, error) {
if s.db == nil {
return RoomPin{}, fmt.Errorf("room mysql is not configured")
}
if pinID <= 0 {
return RoomPin{}, fmt.Errorf("%w: 置顶 ID 不正确", ErrInvalidArgument)
}
nowMS := time.Now().UTC().UnixMilli()
appCode := appctx.FromContext(ctx)
result, err := s.db.ExecContext(ctx, `
UPDATE room_region_pins
SET status = ?, cancelled_at_ms = ?, cancelled_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND id = ? AND status <> ?
`, roomPinStatusCancelled, nowMS, actorID, nowMS, appCode, pinID, roomPinStatusCancelled)
if err != nil {
return RoomPin{}, err
}
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
if _, err := s.getRoomPinByID(ctx, pinID); errors.Is(err, ErrNotFound) {
return RoomPin{}, ErrNotFound
}
}
return s.getRoomPinByID(ctx, pinID)
}
func (s *Service) getRoomForPin(ctx context.Context, roomID string) (RoomPinRoom, int64, error) {
appCode := appctx.FromContext(ctx)
row := s.db.QueryRowContext(ctx, `
SELECT room_id,
room_short_id,
visible_region_id,
owner_user_id,
title,
cover_url,
status
FROM room_list_entries
WHERE app_code = ? AND (room_id = ? OR room_short_id = ?)
LIMIT 1
`, appCode, roomID, roomID)
var room RoomPinRoom
var ownerUserID int64
if err := row.Scan(&room.RoomID, &room.RoomShortID, &room.VisibleRegionID, &ownerUserID, &room.Title, &room.CoverURL, &room.Status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return RoomPinRoom{}, 0, ErrNotFound
}
return RoomPinRoom{}, 0, err
}
if room.Status != "active" {
room.Status = "closed"
}
return room, ownerUserID, nil
}
func (s *Service) getRoomPinByRoom(ctx context.Context, regionID int64, roomID string) (RoomPin, error) {
return s.getRoomPin(ctx, `
WHERE p.app_code = ? AND p.visible_region_id = ? AND p.room_id = ?
`, appctx.FromContext(ctx), regionID, roomID)
}
func (s *Service) getRoomPinByID(ctx context.Context, pinID int64) (RoomPin, error) {
return s.getRoomPin(ctx, `
WHERE p.app_code = ? AND p.id = ?
`, appctx.FromContext(ctx), pinID)
}
func (s *Service) getRoomPin(ctx context.Context, whereSQL string, args ...any) (RoomPin, error) {
row := s.db.QueryRowContext(ctx, `
SELECT p.id,
p.visible_region_id,
p.room_id,
p.weight,
p.status,
p.pinned_at_ms,
p.expires_at_ms,
p.cancelled_at_ms,
p.created_by_admin_id,
p.cancelled_by_admin_id,
p.created_at_ms,
p.updated_at_ms,
r.room_short_id,
r.title,
r.cover_url,
r.owner_user_id,
r.status
FROM room_region_pins p
JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id
`+whereSQL+`
LIMIT 1
`, args...)
item, err := scanRoomPin(row)
if errors.Is(err, sql.ErrNoRows) {
return RoomPin{}, ErrNotFound
}
if err != nil {
return RoomPin{}, err
}
items := []RoomPin{item}
if err := s.fillRoomPinDetails(ctx, items); err != nil {
return RoomPin{}, err
}
return items[0], nil
}
func roomPinListWhere(ctx context.Context, query roomPinListQuery, nowMS int64) (string, []any) {
where := []string{"p.app_code = ?"}
args := []any{appctx.FromContext(ctx)}
switch query.Status {
case roomPinStatusActive:
where = append(where, "p.status = ?", "p.expires_at_ms > ?")
args = append(args, roomPinStatusActive, nowMS)
case roomPinStatusExpired:
where = append(where, "p.status = ?", "p.expires_at_ms <= ?")
args = append(args, roomPinStatusActive, nowMS)
case roomPinStatusCancelled:
where = append(where, "p.status = ?")
args = append(args, roomPinStatusCancelled)
}
if query.RegionID > 0 {
where = append(where, "p.visible_region_id = ?")
args = append(args, query.RegionID)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
where = append(where, "(p.room_id LIKE ? OR r.room_short_id LIKE ? OR r.title LIKE ? OR CAST(r.owner_user_id AS CHAR) LIKE ?)")
args = append(args, like, like, like, like)
}
return `
FROM room_region_pins p
JOIN room_list_entries r ON r.app_code = p.app_code AND r.room_id = p.room_id
WHERE ` + strings.Join(where, " AND "), args
}
func normalizeRoomPinListQuery(query roomPinListQuery) roomPinListQuery {
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 = normalizeRoomPinStatus(query.Status)
if query.RegionID < 0 {
query.RegionID = 0
}
return query
}
func normalizeRoomPinStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "", roomPinStatusActive:
return roomPinStatusActive
case roomPinStatusExpired:
return roomPinStatusExpired
case roomPinStatusCancelled:
return roomPinStatusCancelled
case roomPinStatusAll:
return roomPinStatusAll
default:
return roomPinStatusActive
}
}
func normalizeCreateRoomPinRequest(req createRoomPinRequest) (createRoomPinRequest, error) {
req.RoomID = strings.TrimSpace(req.RoomID)
if req.RoomID == "" || len(req.RoomID) > 64 {
return req, fmt.Errorf("%w: 请选择房间", ErrInvalidArgument)
}
if req.Weight < 0 {
return req, fmt.Errorf("%w: 权重不能小于 0", ErrInvalidArgument)
}
if req.DurationDays == 0 {
req.DurationDays = defaultRoomPinDays
}
if req.DurationDays < 0 || req.DurationDays > maxRoomPinDays {
return req, fmt.Errorf("%w: 置顶时间必须在 1 到 3650 天之间", ErrInvalidArgument)
}
return req, nil
}
type roomPinScanner interface {
Scan(dest ...any) error
}
func scanRoomPin(row roomPinScanner) (RoomPin, error) {
var item RoomPin
var ownerUserID int64
err := row.Scan(
&item.ID,
&item.Room.VisibleRegionID,
&item.Room.RoomID,
&item.Weight,
&item.Status,
&item.PinnedAtMS,
&item.ExpiresAtMS,
&item.CancelledAtMS,
&item.CreatedByAdminID,
&item.CancelledByAdminID,
&item.CreatedAtMS,
&item.UpdatedAtMS,
&item.Room.RoomShortID,
&item.Room.Title,
&item.Room.CoverURL,
&ownerUserID,
&item.Room.Status,
)
item.User = RoomOwner{UserID: strconv.FormatInt(ownerUserID, 10)}
item.RemainingMS = item.ExpiresAtMS - time.Now().UTC().UnixMilli()
if item.RemainingMS < 0 {
item.RemainingMS = 0
}
if item.Room.Status != "active" {
item.Room.Status = "closed"
}
return item, err
}
func (s *Service) fillRoomPinDetails(ctx context.Context, items []RoomPin) 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.User.UserID, 10, 64); err == nil && ownerID > 0 {
ownerIDs = append(ownerIDs, ownerID)
}
if item.Room.VisibleRegionID > 0 {
regionIDs = append(regionIDs, item.Room.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].User.UserID, 10, 64); err == nil {
if owner, ok := owners[ownerID]; ok {
items[index].User = owner
}
}
if name, ok := regions[items[index].Room.VisibleRegionID]; ok {
items[index].Room.RegionName = name
}
}
return nil
}