2026-06-26 21:31:14 +08:00

476 lines
17 KiB
Go

// Package mysql 实现 room-service 的 MySQL 持久化边界。
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"hyapp/pkg/appcode"
roomservice "hyapp/services/room-service/internal/room/service"
)
// GetRoomSeatConfig 读取当前 App 的房间座位数配置。
func (r *Repository) GetRoomSeatConfig(ctx context.Context) (roomservice.RoomSeatConfig, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, allowed_seat_counts, default_seat_count, updated_at_ms
FROM room_seat_configs
WHERE app_code = ?`,
appcode.FromContext(ctx),
)
var config roomservice.RoomSeatConfig
var rawAllowed string
if err := row.Scan(&config.AppCode, &rawAllowed, &config.DefaultSeatCount, &config.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RoomSeatConfig{}, false, nil
}
return roomservice.RoomSeatConfig{}, false, err
}
if err := json.Unmarshal([]byte(rawAllowed), &config.AllowedSeatCounts); err != nil {
return roomservice.RoomSeatConfig{}, false, err
}
return config, true, nil
}
// UpsertRoomSeatConfig 写入当前 App 的房间座位数配置。
func (r *Repository) UpsertRoomSeatConfig(ctx context.Context, config roomservice.RoomSeatConfig) error {
appCode := normalizedRecordAppCode(ctx, config.AppCode)
rawAllowed, err := json.Marshal(config.AllowedSeatCounts)
if err != nil {
return err
}
nowMS := config.UpdatedAtMS
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
_, err = r.db.ExecContext(ctx,
`INSERT INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
allowed_seat_counts = VALUES(allowed_seat_counts),
default_seat_count = VALUES(default_seat_count),
updated_at_ms = VALUES(updated_at_ms)`,
appCode,
string(rawAllowed),
config.DefaultSeatCount,
nowMS,
nowMS,
)
return err
}
// GetRoomRocketConfig 读取当前 App 的语音房火箭配置。
func (r *Repository) GetRoomRocketConfig(ctx context.Context) (roomservice.RoomRocketConfig, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, enabled, config_version, fuel_source, launch_delay_ms,
broadcast_enabled, broadcast_scope, broadcast_delay_ms, reward_stack_policy,
levels_json, gift_fuel_rules_json, updated_by_admin_id, created_at_ms, updated_at_ms
FROM room_rocket_configs
WHERE app_code = ?`,
appcode.FromContext(ctx),
)
var config roomservice.RoomRocketConfig
var enabled int
var broadcastEnabled int
var rawLevels string
var rawRules string
if err := row.Scan(
&config.AppCode,
&enabled,
&config.ConfigVersion,
&config.FuelSource,
&config.LaunchDelayMS,
&broadcastEnabled,
&config.BroadcastScope,
&config.BroadcastDelayMS,
&config.RewardStackPolicy,
&rawLevels,
&rawRules,
&config.UpdatedByAdminID,
&config.CreatedAtMS,
&config.UpdatedAtMS,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RoomRocketConfig{}, false, nil
}
return roomservice.RoomRocketConfig{}, false, err
}
config.Enabled = enabled == 1
config.BroadcastEnabled = broadcastEnabled == 1
if err := json.Unmarshal([]byte(rawLevels), &config.Levels); err != nil {
return roomservice.RoomRocketConfig{}, false, err
}
if err := json.Unmarshal([]byte(rawRules), &config.GiftFuelRules); err != nil {
return roomservice.RoomRocketConfig{}, false, err
}
return config, true, nil
}
// UpsertRoomRocketConfig 写入当前 App 的语音房火箭配置。
func (r *Repository) UpsertRoomRocketConfig(ctx context.Context, config roomservice.RoomRocketConfig) error {
appCode := normalizedRecordAppCode(ctx, config.AppCode)
rawLevels, err := json.Marshal(config.Levels)
if err != nil {
return err
}
rawRules, err := json.Marshal(config.GiftFuelRules)
if err != nil {
return err
}
nowMS := config.UpdatedAtMS
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
createdAtMS := config.CreatedAtMS
if createdAtMS <= 0 {
createdAtMS = nowMS
}
_, err = r.db.ExecContext(ctx,
`INSERT INTO room_rocket_configs (
app_code, enabled, config_version, fuel_source, launch_delay_ms,
broadcast_enabled, broadcast_scope, broadcast_delay_ms, reward_stack_policy,
levels_json, gift_fuel_rules_json, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
config_version = VALUES(config_version),
fuel_source = VALUES(fuel_source),
launch_delay_ms = VALUES(launch_delay_ms),
broadcast_enabled = VALUES(broadcast_enabled),
broadcast_scope = VALUES(broadcast_scope),
broadcast_delay_ms = VALUES(broadcast_delay_ms),
reward_stack_policy = VALUES(reward_stack_policy),
levels_json = VALUES(levels_json),
gift_fuel_rules_json = VALUES(gift_fuel_rules_json),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)`,
appCode,
boolToInt(config.Enabled),
config.ConfigVersion,
config.FuelSource,
config.LaunchDelayMS,
boolToInt(config.BroadcastEnabled),
config.BroadcastScope,
config.BroadcastDelayMS,
config.RewardStackPolicy,
string(rawLevels),
string(rawRules),
config.UpdatedByAdminID,
createdAtMS,
nowMS,
)
return err
}
func (r *Repository) AdminListRooms(ctx context.Context, query roomservice.AdminRoomListQuery) ([]roomservice.AdminRoomListEntry, int64, error) {
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
if query.NowMS <= 0 {
query.NowMS = time.Now().UTC().UnixMilli()
}
where, args := adminRoomWhere(ctx, query)
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_list_entries rle `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
contributionExpr := adminRoomContributionExpr(query.NowMS)
rows, err := r.db.QueryContext(ctx, adminRoomSelectSQL(contributionExpr)+where+` ORDER BY `+adminRoomOrderBy(query, contributionExpr)+` LIMIT ? OFFSET ?`, append(args, query.PageSize, (query.Page-1)*query.PageSize)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]roomservice.AdminRoomListEntry, 0, query.PageSize)
for rows.Next() {
item, err := scanAdminRoom(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r *Repository) AdminGetRoom(ctx context.Context, roomID string, nowMS int64) (roomservice.AdminRoomListEntry, bool, error) {
contributionExpr := adminRoomContributionExpr(nowMS)
row := r.db.QueryRowContext(ctx, adminRoomSelectSQL(contributionExpr)+` WHERE rle.app_code = ? AND rle.room_id = ?`, appcode.FromContext(ctx), strings.TrimSpace(roomID))
item, err := scanAdminRoom(row)
if errors.Is(err, sql.ErrNoRows) {
return roomservice.AdminRoomListEntry{}, false, nil
}
if err != nil {
return roomservice.AdminRoomListEntry{}, false, err
}
return item, true, nil
}
func adminRoomSelectSQL(contributionExpr string) string {
return `
SELECT rle.room_id, rle.room_short_id, rle.visible_region_id, rle.owner_user_id,
rle.title, rle.cover_url, rle.mode, rle.status, rle.close_reason,
rle.closed_by_admin_id, rle.closed_by_admin_name, rle.closed_at_ms,
` + contributionExpr + ` AS room_contribution, rle.online_count, rle.seat_count, rle.occupied_seat_count,
rle.sort_score, rle.created_at_ms, rle.updated_at_ms
FROM room_list_entries rle `
}
func adminRoomWhere(ctx context.Context, query roomservice.AdminRoomListQuery) (string, []any) {
where := []string{"rle.app_code = ?"}
app := appcode.Normalize(query.AppCode)
if app == "" {
app = appcode.FromContext(ctx)
}
args := []any{app}
switch strings.TrimSpace(query.Status) {
case "closed":
where = append(where, "rle.status <> ?")
args = append(args, "active")
case "":
default:
where = append(where, "rle.status = ?")
args = append(args, strings.TrimSpace(query.Status))
}
if query.RegionID > 0 {
where = append(where, "rle.visible_region_id = ?")
args = append(args, query.RegionID)
}
if query.OwnerUserID > 0 {
where = append(where, "rle.owner_user_id = ?")
args = append(args, query.OwnerUserID)
}
if strings.TrimSpace(query.Keyword) != "" {
like := "%" + strings.ReplaceAll(strings.TrimSpace(query.Keyword), "%", "\\%") + "%"
where = append(where, "(rle.room_id LIKE ? ESCAPE '\\\\' OR rle.room_short_id LIKE ? ESCAPE '\\\\' OR rle.title LIKE ? ESCAPE '\\\\' OR CAST(rle.owner_user_id AS CHAR) LIKE ? ESCAPE '\\\\')")
args = append(args, like, like, like, like)
}
return " WHERE " + strings.Join(where, " AND "), args
}
func adminRoomOrderBy(query roomservice.AdminRoomListQuery, contributionExpr string) string {
direction := "DESC"
if strings.EqualFold(strings.TrimSpace(query.SortDirection), "asc") {
direction = "ASC"
}
switch strings.TrimSpace(query.SortBy) {
case "room_contribution", "heat":
return contributionExpr + " " + direction + ", rle.created_at_ms DESC, rle.room_id DESC"
default:
return "rle.created_at_ms DESC, rle.room_id DESC"
}
}
func adminRoomContributionExpr(nowMS int64) string {
weekStartMS := roomContributionWeekStartMS(nowMS)
return "CASE WHEN rle.contribution_week_start_ms = " + strconv.FormatInt(weekStartMS, 10) + " THEN rle.weekly_contribution ELSE 0 END"
}
func roomContributionWeekStartMS(nowMS int64) int64 {
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
dayStart := time.UnixMilli(nowMS).UTC()
dayStart = time.Date(dayStart.Year(), dayStart.Month(), dayStart.Day(), 0, 0, 0, 0, time.UTC)
// 房间贡献榜按 UTC 周一 01:00 切周,给 00:00 后的结算和榜单消费留出缓冲窗口。
cutoff := dayStart.Add(time.Hour)
if time.UnixMilli(nowMS).UTC().Before(cutoff) {
dayStart = dayStart.AddDate(0, 0, -1)
}
weekday := int(dayStart.Weekday())
if weekday == 0 {
weekday = 7
}
return dayStart.AddDate(0, 0, 1-weekday).Add(time.Hour).UnixMilli()
}
func scanAdminRoom(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomListEntry, error) {
var item roomservice.AdminRoomListEntry
err := scanner.Scan(
&item.RoomID,
&item.RoomShortID,
&item.VisibleRegionID,
&item.OwnerUserID,
&item.Title,
&item.CoverURL,
&item.Mode,
&item.Status,
&item.CloseReason,
&item.ClosedByAdminID,
&item.ClosedByAdminName,
&item.ClosedAtMS,
&item.Heat,
&item.OnlineCount,
&item.SeatCount,
&item.OccupiedSeatCount,
&item.SortScore,
&item.CreatedAtMS,
&item.UpdatedAtMS,
)
return item, err
}
func (r *Repository) AdminListRoomPins(ctx context.Context, query roomservice.AdminRoomPinQuery) ([]roomservice.AdminRoomPinEntry, int64, error) {
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
where, args := adminRoomPinWhere(ctx, query)
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) `+adminRoomPinFromSQL()+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := r.db.QueryContext(ctx, adminRoomPinSelectSQL()+where+` ORDER BY p.weight DESC, p.expires_at_ms DESC, p.id DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, (query.Page-1)*query.PageSize)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]roomservice.AdminRoomPinEntry, 0, query.PageSize)
for rows.Next() {
item, err := scanAdminRoomPin(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
return items, total, rows.Err()
}
func (r *Repository) AdminCreateRoomPin(ctx context.Context, input roomservice.AdminCreateRoomPinInput) (roomservice.AdminRoomPinEntry, bool, error) {
app := appcode.FromContext(ctx)
room, exists, err := r.AdminGetRoom(ctx, input.RoomID, input.NowMS)
if err != nil || !exists {
return roomservice.AdminRoomPinEntry{}, false, err
}
if room.Status != "active" {
return roomservice.AdminRoomPinEntry{}, false, nil
}
pinType := strings.TrimSpace(input.PinType)
if pinType == "" {
pinType = "region"
}
visibleRegionID := room.VisibleRegionID
if pinType == "global" {
visibleRegionID = 0
}
pinnedAtMS := input.PinnedAtMS
if pinnedAtMS <= 0 {
pinnedAtMS = input.NowMS
}
expiresAtMS := input.ExpiresAtMS
if expiresAtMS <= 0 {
expiresAtMS = pinnedAtMS + input.DurationDays*24*60*60*1000
}
if _, err := r.db.ExecContext(ctx, `
INSERT INTO room_region_pins (
app_code, visible_region_id, pin_type, 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 (?, ?, ?, ?, ?, 'active', ?, ?, 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)
`, app, visibleRegionID, pinType, room.RoomID, input.Weight, pinnedAtMS, expiresAtMS, input.AdminID, input.NowMS, input.NowMS); err != nil {
return roomservice.AdminRoomPinEntry{}, false, err
}
return r.adminGetRoomPin(ctx, `WHERE p.app_code = ? AND p.pin_type = ? AND p.visible_region_id = ? AND p.room_id = ?`, app, pinType, visibleRegionID, room.RoomID)
}
func (r *Repository) AdminCancelRoomPin(ctx context.Context, pinID int64, adminID uint64, nowMS int64) (roomservice.AdminRoomPinEntry, bool, error) {
app := appcode.FromContext(ctx)
if _, err := r.db.ExecContext(ctx, `
UPDATE room_region_pins
SET status = 'cancelled', cancelled_at_ms = ?, cancelled_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND id = ? AND status <> 'cancelled'
`, nowMS, adminID, nowMS, app, pinID); err != nil {
return roomservice.AdminRoomPinEntry{}, false, err
}
return r.adminGetRoomPin(ctx, `WHERE p.app_code = ? AND p.id = ?`, app, pinID)
}
func (r *Repository) adminGetRoomPin(ctx context.Context, where string, args ...any) (roomservice.AdminRoomPinEntry, bool, error) {
item, err := scanAdminRoomPin(r.db.QueryRowContext(ctx, adminRoomPinSelectSQL()+where+` LIMIT 1`, args...))
if errors.Is(err, sql.ErrNoRows) {
return roomservice.AdminRoomPinEntry{}, false, nil
}
if err != nil {
return roomservice.AdminRoomPinEntry{}, false, err
}
return item, true, nil
}
func adminRoomPinSelectSQL() string {
return `
SELECT p.app_code, p.id, p.visible_region_id, p.pin_type, 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.visible_region_id, r.title, r.cover_url, r.owner_user_id, r.status
` + adminRoomPinFromSQL()
}
func adminRoomPinFromSQL() string {
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 `
}
func adminRoomPinWhere(ctx context.Context, query roomservice.AdminRoomPinQuery) (string, []any) {
nowMS := query.NowMS
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
where := []string{"p.app_code = ?"}
args := []any{appcode.FromContext(ctx)}
switch strings.TrimSpace(query.Status) {
case "active":
where = append(where, "p.status = ?", "p.expires_at_ms > ?")
args = append(args, "active", nowMS)
case "expired":
where = append(where, "p.status = ?", "p.expires_at_ms <= ?")
args = append(args, "active", nowMS)
case "cancelled":
where = append(where, "p.status = ?")
args = append(args, "cancelled")
}
if query.RegionID > 0 {
where = append(where, "p.visible_region_id = ?")
args = append(args, query.RegionID)
}
if strings.TrimSpace(query.PinType) != "" {
where = append(where, "p.pin_type = ?")
args = append(args, strings.TrimSpace(query.PinType))
}
if strings.TrimSpace(query.Keyword) != "" {
like := "%" + strings.ReplaceAll(strings.TrimSpace(query.Keyword), "%", "\\%") + "%"
where = append(where, "(p.room_id LIKE ? ESCAPE '\\\\' OR r.room_short_id LIKE ? ESCAPE '\\\\' OR r.title LIKE ? ESCAPE '\\\\' OR CAST(r.owner_user_id AS CHAR) LIKE ? ESCAPE '\\\\')")
args = append(args, like, like, like, like)
}
return ` WHERE ` + strings.Join(where, " AND "), args
}
func scanAdminRoomPin(scanner interface{ Scan(dest ...any) error }) (roomservice.AdminRoomPinEntry, error) {
var item roomservice.AdminRoomPinEntry
err := scanner.Scan(&item.AppCode, &item.ID, &item.VisibleRegionID, &item.PinType, &item.RoomID, &item.Weight, &item.Status, &item.PinnedAtMS, &item.ExpiresAtMS, &item.CancelledAtMS, &item.CreatedByAdminID, &item.CancelledByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, &item.RoomShortID, &item.RoomVisibleRegionID, &item.Title, &item.CoverURL, &item.OwnerUserID, &item.RoomStatus)
return item, err
}