From 7b2b3a3e2fe3d20242b02b0e5975930187f2c100 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 10 Jun 2026 16:41:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=96=E5=B0=B1=E7=88=B1=E4=BD=A0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/admin/AGENTS.md | 2 +- .../admin/internal/modules/hostorg/handler.go | 7 ++++- .../internal/modules/hostorg/service_test.go | 13 +++++++++ .../internal/room/service/room_rocket.go | 27 +++++++++++++++++++ .../internal/room/service/room_rocket_test.go | 3 +++ .../room-service/internal/room/state/state.go | 4 +-- 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/server/admin/AGENTS.md b/server/admin/AGENTS.md index d2e89492..e0c89ecd 100644 --- a/server/admin/AGENTS.md +++ b/server/admin/AGENTS.md @@ -85,7 +85,7 @@ go run ./cmd/server -config configs/config.yaml -bootstrap 5. 新增任务类型时,需要补齐创建接口、runner handler、重试/失败结果和产物策略。 6. 区域创建和区域启用/禁用是后台管理能力,实现在 `hyapp-admin-server`,不要为此新增 user-service RPC 或把后台开关逻辑下沉到 app 后端服务。 7. 国家创建和国家启用/禁用是后台管理后端能力,实现在 `hyapp-admin-server`,不要为这些后台动作新增 user-service RPC 或 user-service 业务逻辑。 -8. 后台创建 BD Leader 必须传后台选择的 `regionId`;admin-server 负责权限和审计,user-service 在关系创建事务里更新目标用户 `users.region_id`,不要在 admin-server 里用 GetUser 做“用户原区域必须一致”的拒绝。 +8. 后台创建 BD Leader 按目标用户当前 `users.region_id` 创建;admin-server 只负责权限和审计,不传 `regionId`,user-service 在关系创建事务里读取目标用户区域并写入 `bd_leader_profiles.region_id`,不要在创建 BD Leader 时顺手修改用户区域。 9. `hyapp.local/api` 是唯一允许直接依赖的 app 后端契约 module;不要 import `hyapp/services/*`、`hyapp/internal/*` 或 `hyapp/pkg/*`。 ## 验证 diff --git a/server/admin/internal/modules/hostorg/handler.go b/server/admin/internal/modules/hostorg/handler.go index 38f3fc8f..e23146c1 100644 --- a/server/admin/internal/modules/hostorg/handler.go +++ b/server/admin/internal/modules/hostorg/handler.go @@ -143,7 +143,12 @@ func (h *Handler) SetBDStatus(c *gin.Context) { } func bdRoleFromStatusRoute(c *gin.Context) (string, string) { - if strings.HasPrefix(c.FullPath(), "/admin/bd-leaders/") { + return bdRoleFromStatusPath(c.FullPath()) +} + +func bdRoleFromStatusPath(fullPath string) (string, string) { + // FullPath 带有外层 /api/v1 分组;只要命中 BD Leader 状态路由片段,就必须走独立的 bd_leader_profiles,避免误查普通 bd_profiles。 + if strings.Contains(fullPath, "/admin/bd-leaders/") { return "bd_leader", "bd_leader_profiles" } return "bd", "bd_profiles" diff --git a/server/admin/internal/modules/hostorg/service_test.go b/server/admin/internal/modules/hostorg/service_test.go index 7bd84b4a..a5935319 100644 --- a/server/admin/internal/modules/hostorg/service_test.go +++ b/server/admin/internal/modules/hostorg/service_test.go @@ -12,6 +12,19 @@ func TestNormalizeListQueryAllowsCoinSellerComputedSorts(t *testing.T) { } } +// TestBDRoleFromStatusPathUsesLeaderTableWithAPIPrefix 锁定线上真实 FullPath 带 /api/v1 前缀时,BD Leader 状态仍然写独立负责人表。 +func TestBDRoleFromStatusPathUsesLeaderTableWithAPIPrefix(t *testing.T) { + role, target := bdRoleFromStatusPath("/api/v1/admin/bd-leaders/:user_id/status") + if role != "bd_leader" || target != "bd_leader_profiles" { + t.Fatalf("leader status route must use bd_leader_profiles, got role=%q target=%q", role, target) + } + + role, target = bdRoleFromStatusPath("/api/v1/admin/bds/:user_id/status") + if role != "bd" || target != "bd_profiles" { + t.Fatalf("bd status route must use bd_profiles, got role=%q target=%q", role, target) + } +} + // TestSortCoinSellerListItemsComputedFields 验证 wallet 聚合字段排序只改变展示顺序,不改动币商身份数据。 func TestSortCoinSellerListItemsComputedFields(t *testing.T) { items := []*CoinSellerListItem{ diff --git a/services/room-service/internal/room/service/room_rocket.go b/services/room-service/internal/room/service/room_rocket.go index f120986f..32000158 100644 --- a/services/room-service/internal/room/service/room_rocket.go +++ b/services/room-service/internal/room/service/room_rocket.go @@ -496,6 +496,7 @@ func rocketStateForNow(input *state.RocketState, cfg RoomRocketConfig, now time. rocket.CurrentFuel = rocket.FuelThreshold } rocket.PendingLaunches = validRocketPendingLaunches(rocket.PendingLaunches, rocket.ResetAtMS) + syncRocketLaunchSnapshotFromPending(rocket) if rocket.Status == state.RocketStatusCompleted { rocket.CurrentLevel = roomRocketLevelCount rocket.FuelThreshold = roomRocketLevelByNumber(cfg, roomRocketLevelCount).FuelThreshold @@ -510,6 +511,31 @@ func rocketStateForNow(input *state.RocketState, cfg RoomRocketConfig, now time. return rocket } +func syncRocketLaunchSnapshotFromPending(rocket *state.RocketState) { + if rocket == nil { + return + } + if len(rocket.PendingLaunches) == 0 { + // 当前充能档没有待发射火箭时,不能继续暴露上一枚火箭的发射时间,否则客户端会把过期 launch_at_ms 渲染成 00:00:00。 + rocket.IgnitedAtMS = 0 + rocket.LaunchAtMS = 0 + rocket.Top1UserID = 0 + rocket.IgniterUserID = 0 + return + } + next := rocket.PendingLaunches[0] + for _, launch := range rocket.PendingLaunches[1:] { + if launch.LaunchAtMS > 0 && (next.LaunchAtMS <= 0 || launch.LaunchAtMS < next.LaunchAtMS) { + next = launch + } + } + // RoomRocketState 上的倒计时字段兼容旧客户端;真实多枚队列仍以 PendingLaunches 为准。 + rocket.IgnitedAtMS = next.IgnitedAtMS + rocket.LaunchAtMS = next.LaunchAtMS + rocket.Top1UserID = next.Top1UserID + rocket.IgniterUserID = next.IgniterUserID +} + func validRocketPendingLaunches(input []state.RocketPendingLaunch, resetAtMS int64) []state.RocketPendingLaunch { out := make([]state.RocketPendingLaunch, 0, len(input)) seen := make(map[string]bool, len(input)) @@ -821,6 +847,7 @@ func (s *Service) launchRoomRocket(ctx context.Context, cmd command.LaunchRoomRo pendingAfter := removeRocketPendingLaunch(current.Rocket.PendingLaunches, pendingIndex) current.Rocket.PendingLaunches = pendingAfter + syncRocketLaunchSnapshotFromPending(current.Rocket) current.Rocket.LaunchedAtMS = nowMS current.Rocket.LastRewards = rewards diff --git a/services/room-service/internal/room/service/room_rocket_test.go b/services/room-service/internal/room/service/room_rocket_test.go index 64aae985..92aace1f 100644 --- a/services/room-service/internal/room/service/room_rocket_test.go +++ b/services/room-service/internal/room/service/room_rocket_test.go @@ -425,6 +425,9 @@ func TestRoomRocketLaunchGrantsLockedTop1AndIgniterAfterLeave(t *testing.T) { if stateView.GetCurrentLevel() != 2 || stateView.GetStatus() != state.RocketStatusCharging || len(stateView.GetPendingLaunches()) != 0 { t.Fatalf("launch rocket must keep current next-level progress and clear pending launch: %+v", stateView) } + if stateView.GetLaunchAtMs() != 0 || stateView.GetIgnitedAtMs() != 0 || stateView.GetTop1UserId() != 0 || stateView.GetIgniterUserId() != 0 { + t.Fatalf("launched rocket must not leak expired launch countdown into next charging level: %+v", stateView) + } if len(stateView.GetLastRewards()) != 3 { t.Fatalf("launch rocket must retain in-room, top1 and igniter grants for client display: %+v", stateView.GetLastRewards()) } diff --git a/services/room-service/internal/room/state/state.go b/services/room-service/internal/room/state/state.go index 0c9eec7b..5bde4274 100644 --- a/services/room-service/internal/room/state/state.go +++ b/services/room-service/internal/room/state/state.go @@ -181,13 +181,13 @@ type RocketState struct { FuelThreshold int64 // Status 区分 charging/completed;已点火火箭在 PendingLaunches 中独立排队,不阻塞当前进度。 Status string - // IgnitedAtMS/LaunchAtMS 保留最近一次点火快照,客户端可用 PendingLaunches 获得完整队列。 + // IgnitedAtMS/LaunchAtMS 投影下一枚待发射火箭,完整多枚队列仍以 PendingLaunches 为准。 IgnitedAtMS int64 LaunchAtMS int64 LaunchedAtMS int64 // ResetAtMS 是下一次 UTC 自然日重置毫秒时间。 ResetAtMS int64 - // Top1UserID 和 IgniterUserID 在燃料满瞬间锁定,用户离房或下线仍按这里发奖。 + // Top1UserID 和 IgniterUserID 投影下一枚待发射火箭的锁定用户;发奖以 PendingLaunches 内快照为准。 Top1UserID int64 IgniterUserID int64 // RocketID 是单次火箭从积攒到发射的幂等标识,奖励抽取和 wallet command_id 都依赖它。