修复排行榜
This commit is contained in:
parent
5000076365
commit
245412ae95
@ -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 {
|
||||
|
||||
@ -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? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user