修复排行榜

This commit is contained in:
zhx 2026-05-25 22:43:54 +08:00
parent 5000076365
commit 245412ae95
6 changed files with 221 additions and 46 deletions

View File

@ -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 {

View File

@ -100,7 +100,7 @@ Authorization: Bearer <access_token>
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<String, dynamic> 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? ?? '',
);
}
}
```

View File

@ -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,

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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,