From 245412ae95d007bb55ce25ad0901a6898dc18155 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 25 May 2026 22:43:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=92=E8=A1=8C=E6=A6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/dev-run/main.go | 163 +++++++++++++----- docs/flutter对接/用户排行榜Flutter对接.md | 24 ++- .../transport/http/activityapi/handler.go | 3 + .../activityapi/user_leaderboard_handler.go | 56 +++++- .../user_leaderboard_handler_test.go | 20 +++ .../internal/transport/http/router.go | 1 + 6 files changed, 221 insertions(+), 46 deletions(-) diff --git a/cmd/dev-run/main.go b/cmd/dev-run/main.go index f4355b54..7c316c0b 100644 --- a/cmd/dev-run/main.go +++ b/cmd/dev-run/main.go @@ -13,6 +13,7 @@ import ( "os/signal" "path/filepath" "sort" + "strconv" "strings" "sync" "syscall" @@ -30,6 +31,8 @@ type serviceSpec struct { Dir string Args []string WatchRoot []string + Ports []int + Color string } type serviceRunner struct { @@ -41,6 +44,8 @@ type serviceRunner struct { stopping bool } +var logMu sync.Mutex + func main() { serviceList := flag.String("services", "all", "comma-separated services to run, or all") flag.Parse() @@ -65,14 +70,15 @@ func main() { for _, spec := range specs { runner := &serviceRunner{spec: spec} runners[spec.Name] = runner + killPortOwners(spec) if err := runner.start(root); err != nil { - fmt.Fprintf(os.Stderr, "[%s] start failed: %v\n", spec.Name, err) + logf(os.Stderr, spec, "start failed: %v", err) } watchers = append(watchers, newWatchState(root, spec)) } - fmt.Printf("dev-run watching %d service(s): %s\n", len(specs), serviceNames(specs)) - fmt.Println("press Ctrl-C to stop all services") + printPlain(os.Stdout, "dev-run watching %d service(s): %s", len(specs), serviceNames(specs)) + printPlain(os.Stdout, "press Ctrl-C to stop all services") ticker := time.NewTicker(scanInterval) defer ticker.Stop() @@ -87,7 +93,7 @@ func main() { for _, watcher := range watchers { changed, err := watcher.changed() if err != nil { - fmt.Fprintf(os.Stderr, "[%s] watch failed: %v\n", watcher.spec.Name, err) + logf(os.Stderr, watcher.spec, "watch failed: %v", err) continue } if changed { @@ -100,7 +106,7 @@ func main() { } delete(pending, name) runner := runners[name] - fmt.Printf("[%s] change detected, restarting\n", name) + logf(os.Stdout, runner.spec, "change detected, restarting") runner.restart(root) } } @@ -110,14 +116,15 @@ func main() { func allServices() []serviceSpec { // 启动顺序按低层依赖到入口服务排列;gRPC dial 当前不阻塞,但这样能让日志更接近真实依赖链路。 return []serviceSpec{ - appService("user-service"), - appService("activity-service"), - appService("wallet-service"), - appService("game-service"), - appService("room-service"), - appService("notice-service"), - appService("cron-service"), - appService("gateway-service"), + appService("user-service", []int{13005, 13105}, "36"), + appService("activity-service", []int{13006, 13106}, "35"), + appService("wallet-service", []int{13004, 13104}, "33"), + appService("game-service", []int{13008, 13108}, "32"), + appService("room-service", []int{13001, 13101}, "34"), + appService("notice-service", []int{13009, 13109}, "96"), + appService("cron-service", []int{13007, 13107}, "95"), + appService("gateway-service", []int{13000}, "92"), + appService("statistics-service", []int{13010, 13110}, "94"), { Name: "admin", Dir: "server/admin", @@ -126,11 +133,13 @@ func allServices() []serviceSpec { "server/admin", "api", }, + Ports: []int{13100}, + Color: "91", }, } } -func appService(name string) serviceSpec { +func appService(name string, ports []int, color string) serviceSpec { return serviceSpec{ Name: name, Dir: ".", @@ -140,6 +149,8 @@ func appService(name string) serviceSpec { "pkg", "api", }, + Ports: ports, + Color: color, } } @@ -153,22 +164,24 @@ func selectServices(raw string) ([]serviceSpec, error) { byName[spec.Name] = spec } aliases := map[string]string{ - "gateway": "gateway-service", - "gs": "gateway-service", - "room": "room-service", - "rs": "room-service", - "wallet": "wallet-service", - "ws": "wallet-service", - "user": "user-service", - "us": "user-service", - "activity": "activity-service", - "as": "activity-service", - "cron": "cron-service", - "cs": "cron-service", - "game": "game-service", - "games": "game-service", - "notice": "notice-service", - "ns": "notice-service", + "gateway": "gateway-service", + "gs": "gateway-service", + "room": "room-service", + "rs": "room-service", + "wallet": "wallet-service", + "ws": "wallet-service", + "user": "user-service", + "us": "user-service", + "activity": "activity-service", + "as": "activity-service", + "cron": "cron-service", + "cs": "cron-service", + "game": "game-service", + "games": "game-service", + "notice": "notice-service", + "ns": "notice-service", + "stats": "statistics-service", + "statistics": "statistics-service", } var selected []serviceSpec seen := map[string]bool{} @@ -217,18 +230,19 @@ func (r *serviceRunner) start(repoRoot string) error { r.done = make(chan struct{}) r.stopping = false - go prefixPipe(r.spec.Name, stdout) - go prefixPipe(r.spec.Name, stderr) + go prefixPipe(r.spec, stdout) + go prefixPipe(r.spec, stderr) go r.wait(cmd, r.done) - fmt.Printf("[%s] started: go %s\n", r.spec.Name, strings.Join(r.spec.Args, " ")) + logf(os.Stdout, r.spec, "started: go %s", strings.Join(r.spec.Args, " ")) return nil } func (r *serviceRunner) restart(repoRoot string) { r.stop() + killPortOwners(r.spec) if err := r.start(repoRoot); err != nil { - fmt.Fprintf(os.Stderr, "[%s] restart failed: %v\n", r.spec.Name, err) + logf(os.Stderr, r.spec, "restart failed: %v", err) } } @@ -266,13 +280,13 @@ func (r *serviceRunner) wait(cmd *exec.Cmd, done chan struct{}) { if err != nil { if stopping { - fmt.Printf("[%s] stopped\n", r.spec.Name) + logf(os.Stdout, r.spec, "stopped") return } - fmt.Fprintf(os.Stderr, "[%s] exited: %v\n", r.spec.Name, err) + logf(os.Stderr, r.spec, "exited: %v", err) return } - fmt.Printf("[%s] exited\n", r.spec.Name) + logf(os.Stdout, r.spec, "exited") } func stopAll(runners map[string]*serviceRunner) { @@ -400,18 +414,87 @@ func sameSnapshot(a, b map[string]fileStamp) bool { return true } -func prefixPipe(name string, reader io.Reader) { +func killPortOwners(spec serviceSpec) { + seen := map[int]bool{} + for _, port := range spec.Ports { + if port <= 0 || seen[port] { + continue + } + seen[port] = true + pids, err := listeningPIDs(port) + if err != nil { + logf(os.Stderr, spec, "port %d lookup failed: %v", port, err) + continue + } + if len(pids) == 0 { + continue + } + for _, pid := range pids { + // 本地开发启动以抢占端口为准:旧进程可能是 go run 托管的子进程,直接杀监听 PID 最可靠。 + if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + logf(os.Stderr, spec, "kill pid %d on port %d failed: %v", pid, port, err) + continue + } + logf(os.Stdout, spec, "killed pid %d occupying port %d", pid, port) + } + } +} + +func listeningPIDs(port int) ([]int, error) { + output, err := exec.Command("lsof", "-nP", "-tiTCP:"+strconv.Itoa(port), "-sTCP:LISTEN").Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && len(output) == 0 { + return nil, nil + } + return nil, err + } + lines := strings.Fields(string(output)) + pids := make([]int, 0, len(lines)) + for _, line := range lines { + pid, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil { + continue + } + pids = append(pids, pid) + } + return pids, nil +} + +func prefixPipe(spec serviceSpec, reader io.Reader) { scanner := bufio.NewScanner(reader) buf := make([]byte, 0, 64*1024) scanner.Buffer(buf, 1024*1024) for scanner.Scan() { - fmt.Printf("[%s] %s\n", name, scanner.Text()) + logf(os.Stdout, spec, "%s", scanner.Text()) } if err := scanner.Err(); err != nil { - fmt.Fprintf(os.Stderr, "[%s] log pipe failed: %v\n", name, err) + logf(os.Stderr, spec, "log pipe failed: %v", err) } } +func logf(w io.Writer, spec serviceSpec, format string, args ...any) { + line := fmt.Sprintf(format, args...) + prefix := "[" + spec.Name + "]" + if colorEnabled() && spec.Color != "" { + prefix = "\x1b[" + spec.Color + "m" + prefix + "\x1b[0m" + } + printPlain(w, "%s %s", prefix, line) +} + +func printPlain(w io.Writer, format string, args ...any) { + logMu.Lock() + defer logMu.Unlock() + fmt.Fprintf(w, format+"\n", args...) +} + +func colorEnabled() bool { + if _, ok := os.LookupEnv("NO_COLOR"); ok { + return false + } + return os.Getenv("TERM") != "dumb" +} + func serviceNames(specs []serviceSpec) string { names := make([]string, 0, len(specs)) for _, spec := range specs { diff --git a/docs/flutter对接/用户排行榜Flutter对接.md b/docs/flutter对接/用户排行榜Flutter对接.md index 4c57e410..6a056840 100644 --- a/docs/flutter对接/用户排行榜Flutter对接.md +++ b/docs/flutter对接/用户排行榜Flutter对接.md @@ -100,7 +100,7 @@ Authorization: Bearer X-App-Code: lalu ``` -房间榜按 `room_id` 聚合。返回项里只有 `room_id` 和 `room`,不会返回 `user` 和 `my_rank`。 +房间榜按 `room_id` 聚合。返回项里不会返回 `user` 和 `my_rank`;`room` 会补充房间短号、名称和头像字段,客户端头像优先读 `cover_url`,兼容字段为 `room_avatar`。 ## 房间内贡献榜边界 @@ -224,7 +224,7 @@ X-App-Code: lalu | `transaction_count` | int64 | 统计窗口内成功送礼扣费流水数。 | | `last_gift_at_ms` | int64 | 该主体最近一次成功送礼流水时间。 | | `user` | object? | 用户资料;正常会补充展示 ID、昵称和头像;本地未注入用户资料 client 时至少返回 `user_id`。 | -| `room` | object? | 房间基础投影;当前只有 `room_id`。 | +| `room` | object? | 房间基础投影;房间榜返回 `room_id`、`room_short_id`、`title`、`cover_url`、`room_avatar`。 | `user` 字段: @@ -376,12 +376,28 @@ class LeaderboardUser { } class LeaderboardRoom { - LeaderboardRoom({required this.roomId}); + LeaderboardRoom({ + required this.roomId, + required this.roomShortId, + required this.title, + required this.coverUrl, + required this.roomAvatar, + }); final String roomId; + final String roomShortId; + final String title; + final String coverUrl; + final String roomAvatar; factory LeaderboardRoom.fromJson(Map json) { - return LeaderboardRoom(roomId: json['room_id'] as String? ?? ''); + return LeaderboardRoom( + roomId: json['room_id'] as String? ?? '', + roomShortId: json['room_short_id'] as String? ?? '', + title: json['title'] as String? ?? '', + coverUrl: json['cover_url'] as String? ?? '', + roomAvatar: json['room_avatar'] as String? ?? '', + ); } } ``` diff --git a/services/gateway-service/internal/transport/http/activityapi/handler.go b/services/gateway-service/internal/transport/http/activityapi/handler.go index 60f1c0c9..78978a18 100644 --- a/services/gateway-service/internal/transport/http/activityapi/handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/handler.go @@ -14,6 +14,7 @@ type Handler struct { growthLevelClient client.GrowthLevelClient achievementClient client.AchievementClient userProfileClient client.UserProfileClient + roomQueryClient client.RoomQueryClient walletClient client.WalletClient walletDB *sql.DB registrationReward client.RegistrationRewardClient @@ -26,6 +27,7 @@ type Config struct { GrowthLevelClient client.GrowthLevelClient AchievementClient client.AchievementClient UserProfileClient client.UserProfileClient + RoomQueryClient client.RoomQueryClient WalletClient client.WalletClient WalletDB *sql.DB RegistrationReward client.RegistrationRewardClient @@ -39,6 +41,7 @@ func New(config Config) *Handler { growthLevelClient: config.GrowthLevelClient, achievementClient: config.AchievementClient, userProfileClient: config.UserProfileClient, + roomQueryClient: config.RoomQueryClient, walletClient: config.WalletClient, walletDB: config.WalletDB, registrationReward: config.RegistrationReward, diff --git a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go index 0ac21de7..382c2abc 100644 --- a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go +++ b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler.go @@ -14,6 +14,7 @@ import ( "hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/transport/http/httpkit" + roomv1 "hyapp.local/api/proto/room/v1" userv1 "hyapp.local/api/proto/user/v1" ) @@ -62,7 +63,11 @@ type userLeaderboardUser struct { } type userLeaderboardRoom struct { - RoomID string `json:"room_id"` + RoomID string `json:"room_id"` + RoomShortID string `json:"room_short_id,omitempty"` + Title string `json:"title,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + RoomAvatar string `json:"room_avatar,omitempty"` } func (h *Handler) listUserLeaderboards(writer http.ResponseWriter, request *http.Request) { @@ -362,7 +367,7 @@ func userLeaderboardDimension(boardType string) (string, string) { func (h *Handler) enrichUserLeaderboardItems(request *http.Request, boardType string, items []userLeaderboardItem, myRank *userLeaderboardItem) error { if boardType == leaderboardTypeRoom { - return nil + return h.enrichRoomLeaderboardItems(request, items) } userIDs := make([]int64, 0, len(items)+1) for _, item := range items { @@ -388,6 +393,53 @@ func (h *Handler) enrichUserLeaderboardItems(request *http.Request, boardType st return nil } +func (h *Handler) enrichRoomLeaderboardItems(request *http.Request, items []userLeaderboardItem) error { + if h.roomQueryClient == nil || len(items) == 0 { + return nil + } + seen := make(map[string]*userLeaderboardRoom, len(items)) + for i := range items { + roomID := strings.TrimSpace(items[i].RoomID) + if roomID == "" { + continue + } + room, ok := seen[roomID] + if !ok { + resp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{ + Meta: httpkit.RoomMeta(request, roomID, ""), + RoomId: roomID, + ViewerUserId: auth.UserIDFromContext(request.Context()), + }) + if err != nil { + return err + } + room = roomLeaderboardRoomFromSnapshot(roomID, resp.GetRoom()) + seen[roomID] = room + } + items[i].Room = room + } + return nil +} + +func roomLeaderboardRoomFromSnapshot(roomID string, snapshot *roomv1.RoomSnapshot) *userLeaderboardRoom { + room := &userLeaderboardRoom{RoomID: roomID} + if snapshot == nil { + return room + } + if snapshot.GetRoomId() != "" { + room.RoomID = snapshot.GetRoomId() + } + ext := snapshot.GetRoomExt() + room.RoomShortID = snapshot.GetRoomShortId() + if room.RoomShortID == "" { + room.RoomShortID = ext["room_short_id"] + } + room.Title = ext["title"] + room.CoverURL = ext["cover_url"] + room.RoomAvatar = room.CoverURL + return room +} + func (h *Handler) userLeaderboardProfiles(request *http.Request, userIDs []int64) (map[int64]*userv1.User, error) { result := make(map[int64]*userv1.User) if h.userProfileClient == nil || len(userIDs) == 0 { diff --git a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go index a382de15..4761c1ac 100644 --- a/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go +++ b/services/gateway-service/internal/transport/http/activityapi/user_leaderboard_handler_test.go @@ -3,6 +3,8 @@ package activityapi import ( "testing" "time" + + roomv1 "hyapp.local/api/proto/room/v1" ) func TestUserLeaderboardPeriodWindowUsesUTCBoundaries(t *testing.T) { @@ -38,3 +40,21 @@ func TestNormalizeUserLeaderboardTypeAndPeriod(t *testing.T) { t.Fatal("monthly should normalize to month") } } + +func TestRoomLeaderboardRoomFromSnapshotIncludesAvatar(t *testing.T) { + room := roomLeaderboardRoomFromSnapshot("room_1", &roomv1.RoomSnapshot{ + RoomId: "room_1", + RoomShortId: "1001", + RoomExt: map[string]string{ + "title": "Music Room", + "cover_url": "https://cdn.example/room.png", + }, + }) + + if room.RoomID != "room_1" || room.RoomShortID != "1001" || room.Title != "Music Room" { + t.Fatalf("room profile mismatch: %+v", room) + } + if room.CoverURL != "https://cdn.example/room.png" || room.RoomAvatar != room.CoverURL { + t.Fatalf("room avatar fields mismatch: %+v", room) + } +} diff --git a/services/gateway-service/internal/transport/http/router.go b/services/gateway-service/internal/transport/http/router.go index 2501effc..af16cd60 100644 --- a/services/gateway-service/internal/transport/http/router.go +++ b/services/gateway-service/internal/transport/http/router.go @@ -62,6 +62,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler { GrowthLevelClient: h.growthLevelClient, AchievementClient: h.achievementClient, UserProfileClient: h.userProfileClient, + RoomQueryClient: h.roomQueryClient, WalletClient: h.walletClient, WalletDB: h.leaderboardWalletDB, RegistrationReward: h.registrationReward,