Skip to content

Commit 30f1ede

Browse files
committed
refactor(sync): rooms.go/relay.go 按职责拆分 5 文件(manager/handlers/ws),全部 ≤300 行
1 parent c2eefff commit 30f1ede

6 files changed

Lines changed: 700 additions & 662 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// @ai-context
2+
// 本文件由 relay.go 拆分而来(拆分日期 2026-08):接力 HTTP/WS 处理器与消息载荷。
3+
// Splitted from relay.go (2026-08): relay HTTP/WS handlers and message payloads.
4+
// 职责:RelayPairRequest/RelayEnd/RelayAccept/RelayComplete/RelayStats 五个 Gin
5+
// handler 与 handleRelayWSMessage(relay:partner-status 转发,4.2 协议)。
6+
// 仅做文件切分,零行为改变。
7+
package handlers
8+
9+
import (
10+
"encoding/json"
11+
"net/http"
12+
13+
"github.com/gin-gonic/gin"
14+
)
15+
16+
// ---------- HTTP handlers ----------
17+
18+
// RelayPairRequest handles POST /api/v1/relay/pair
19+
func RelayPairRequest(c *gin.Context) {
20+
userID := c.GetString("user_id")
21+
var req struct {
22+
PartnerUserID string `json:"partnerUserId"`
23+
}
24+
if err := c.ShouldBindJSON(&req); err != nil || req.PartnerUserID == "" {
25+
c.JSON(http.StatusBadRequest, gin.H{"error": "partnerUserId is required"})
26+
return
27+
}
28+
// WG-1: 对方 ID 做长度/字符集白名单校验,防止任意超长参数与 WS 洪泛
29+
if !isValidDeviceID(req.PartnerUserID) {
30+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid partnerUserId: must match [A-Za-z0-9_-]{1,64}"})
31+
return
32+
}
33+
p, err := relayManager.createPair(userID, req.PartnerUserID)
34+
if err != nil {
35+
c.JSON(relayErrorStatus(err), gin.H{"error": err.Error()})
36+
return
37+
}
38+
// 经 WS 通知对方(对方离线则静默跳过)
39+
wsManager.sendToUser(p.UserB, marshalWSMessage("relay:request", gin.H{
40+
"pairId": p.PairID, "fromUserId": p.UserA, "status": p.Status,
41+
}))
42+
c.JSON(http.StatusOK, p)
43+
}
44+
45+
// RelayEnd handles POST /api/v1/relay/end:任一方结束配对,通知搭档。
46+
func RelayEnd(c *gin.Context) {
47+
userID := c.GetString("user_id")
48+
var req struct {
49+
PairID string `json:"pairId"`
50+
}
51+
if err := c.ShouldBindJSON(&req); err != nil || req.PairID == "" {
52+
c.JSON(http.StatusBadRequest, gin.H{"error": "pairId is required"})
53+
return
54+
}
55+
partner, ok := relayManager.partnerOf(req.PairID, userID)
56+
if !ok {
57+
c.JSON(relayErrorStatus(errNotInPair), gin.H{"error": "pair not found or not a member"})
58+
return
59+
}
60+
if err := relayManager.endPair(req.PairID, userID); err != nil {
61+
c.JSON(relayErrorStatus(err), gin.H{"error": err.Error()})
62+
return
63+
}
64+
wsManager.sendToUser(partner, marshalWSMessage("relay:ended", gin.H{
65+
"pairId": req.PairID, "userId": userID,
66+
}))
67+
c.JSON(http.StatusOK, gin.H{"pairId": req.PairID, "status": "ended"})
68+
}
69+
70+
// RelayAccept handles POST /api/v1/relay/accept
71+
func RelayAccept(c *gin.Context) {
72+
userID := c.GetString("user_id")
73+
var req struct {
74+
PairID string `json:"pairId"`
75+
}
76+
if err := c.ShouldBindJSON(&req); err != nil || req.PairID == "" {
77+
c.JSON(http.StatusBadRequest, gin.H{"error": "pairId is required"})
78+
return
79+
}
80+
p, err := relayManager.acceptPair(req.PairID, userID)
81+
if err != nil {
82+
c.JSON(relayErrorStatus(err), gin.H{"error": err.Error()})
83+
return
84+
}
85+
// 通知发起方:已被接受
86+
wsManager.sendToUser(p.UserA, marshalWSMessage("relay:accepted", gin.H{
87+
"pairId": p.PairID, "userId": userID, "status": p.Status,
88+
}))
89+
c.JSON(http.StatusOK, p)
90+
}
91+
92+
// RelayComplete handles POST /api/v1/relay/complete
93+
func RelayComplete(c *gin.Context) {
94+
userID := c.GetString("user_id")
95+
var req struct {
96+
PairID string `json:"pairId"`
97+
FocusMinutes int `json:"focusMinutes"`
98+
}
99+
if err := c.ShouldBindJSON(&req); err != nil || req.PairID == "" {
100+
c.JSON(http.StatusBadRequest, gin.H{"error": "pairId is required"})
101+
return
102+
}
103+
p, err := relayManager.completeTurn(req.PairID, userID, req.FocusMinutes)
104+
if err != nil {
105+
c.JSON(relayErrorStatus(err), gin.H{"error": err.Error()})
106+
return
107+
}
108+
// 通知搭档:本轮完成
109+
if partner, ok := relayManager.partnerOf(p.PairID, userID); ok {
110+
wsManager.sendToUser(partner, marshalWSMessage("relay:turn-complete", gin.H{
111+
"pairId": p.PairID, "userId": userID,
112+
"relayCount": p.RelayCount, "totalFocusMinutes": p.TotalFocusMinutes,
113+
}))
114+
}
115+
c.JSON(http.StatusOK, p)
116+
}
117+
118+
// RelayStats handles GET /api/v1/relay/stats
119+
func RelayStats(c *gin.Context) {
120+
userID := c.GetString("user_id")
121+
stats, pairs := relayManager.userStats(userID)
122+
c.JSON(http.StatusOK, gin.H{
123+
"relayCount": stats.RelayCount,
124+
"totalFocusMinutes": stats.TotalFocusMinutes,
125+
"pairs": pairs,
126+
})
127+
}
128+
129+
// ---------- WebSocket 消息处理(4.2 协议) ----------
130+
131+
// handleRelayWSMessage 处理 "relay:partner-status":客户端上报自己的番茄钟状态,
132+
// 服务器校验其确为配对成员后转发给搭档(状态仅限枚举,不携带内容)。
133+
func handleRelayWSMessage(c *WSConnection, msg WSMessage) {
134+
var payload struct {
135+
PairID string `json:"pairId"`
136+
Status string `json:"status"` // focusing | break | done(与房间成员状态一致)
137+
}
138+
if err := json.Unmarshal(msg.Payload, &payload); err != nil || payload.PairID == "" {
139+
return
140+
}
141+
if !validRoomMemberStatus(payload.Status) {
142+
return
143+
}
144+
partner, ok := relayManager.partnerOf(payload.PairID, c.UserID)
145+
if !ok {
146+
return
147+
}
148+
wsManager.sendToUser(partner, marshalWSMessage("relay:partner-status", gin.H{
149+
"pairId": payload.PairID, "userId": c.UserID, "status": payload.Status,
150+
}))
151+
}

server/sync-service/handlers/relay.go renamed to server/sync-service/handlers/relay_manager.go

Lines changed: 6 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
// @ai-context
2-
// Phase 4.2 番茄钟协作接力(Pomodoro Relay):双人结对、轮次接力与个人统计(纯内存)。
3-
// Pomodoro relay: two-person pairing, turn relay and per-user stats, all in-memory.
4-
// Why: 接力关系是短期临时协作,无需落库;配对请求经 WS relay:request 通知对方。
2+
// 本文件由 relay.go 拆分而来(拆分日期 2026-08):接力配对领域模型与状态机。
3+
// Splitted from relay.go (2026-08): relay pair domain types and state machine.
4+
// 职责:RelayPair/RelayUserStats/RelayManager 结构体、TTL 常量、relayManager
5+
// 单例与 init()、错误哨兵、核心方法(createPair/acceptPair/completeTurn/endPair/
6+
// userStats/partnerOf/cleanupLoop/cleanupExpired)与 relayErrorStatus 状态映射。
7+
// 仅做文件切分,零行为改变。
58
package handlers
69

710
import (
8-
"encoding/json"
911
"errors"
1012
"log"
1113
"net/http"
1214
"strconv"
1315
"sync"
1416
"time"
15-
16-
"github.com/gin-gonic/gin"
1717
)
1818

1919
// RelayPair 一条接力配对关系(waiting:待对方接受;running:接力进行中)。
@@ -241,140 +241,3 @@ func relayErrorStatus(err error) int {
241241
return http.StatusBadRequest
242242
}
243243
}
244-
245-
// ---------- HTTP handlers ----------
246-
247-
// RelayPairRequest handles POST /api/v1/relay/pair
248-
func RelayPairRequest(c *gin.Context) {
249-
userID := c.GetString("user_id")
250-
var req struct {
251-
PartnerUserID string `json:"partnerUserId"`
252-
}
253-
if err := c.ShouldBindJSON(&req); err != nil || req.PartnerUserID == "" {
254-
c.JSON(http.StatusBadRequest, gin.H{"error": "partnerUserId is required"})
255-
return
256-
}
257-
// WG-1: 对方 ID 做长度/字符集白名单校验,防止任意超长参数与 WS 洪泛
258-
if !isValidDeviceID(req.PartnerUserID) {
259-
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid partnerUserId: must match [A-Za-z0-9_-]{1,64}"})
260-
return
261-
}
262-
p, err := relayManager.createPair(userID, req.PartnerUserID)
263-
if err != nil {
264-
c.JSON(relayErrorStatus(err), gin.H{"error": err.Error()})
265-
return
266-
}
267-
// 经 WS 通知对方(对方离线则静默跳过)
268-
wsManager.sendToUser(p.UserB, marshalWSMessage("relay:request", gin.H{
269-
"pairId": p.PairID, "fromUserId": p.UserA, "status": p.Status,
270-
}))
271-
c.JSON(http.StatusOK, p)
272-
}
273-
274-
// RelayEnd handles POST /api/v1/relay/end:任一方结束配对,通知搭档。
275-
func RelayEnd(c *gin.Context) {
276-
userID := c.GetString("user_id")
277-
var req struct {
278-
PairID string `json:"pairId"`
279-
}
280-
if err := c.ShouldBindJSON(&req); err != nil || req.PairID == "" {
281-
c.JSON(http.StatusBadRequest, gin.H{"error": "pairId is required"})
282-
return
283-
}
284-
partner, ok := relayManager.partnerOf(req.PairID, userID)
285-
if !ok {
286-
c.JSON(relayErrorStatus(errNotInPair), gin.H{"error": "pair not found or not a member"})
287-
return
288-
}
289-
if err := relayManager.endPair(req.PairID, userID); err != nil {
290-
c.JSON(relayErrorStatus(err), gin.H{"error": err.Error()})
291-
return
292-
}
293-
wsManager.sendToUser(partner, marshalWSMessage("relay:ended", gin.H{
294-
"pairId": req.PairID, "userId": userID,
295-
}))
296-
c.JSON(http.StatusOK, gin.H{"pairId": req.PairID, "status": "ended"})
297-
}
298-
299-
// RelayAccept handles POST /api/v1/relay/accept
300-
func RelayAccept(c *gin.Context) {
301-
userID := c.GetString("user_id")
302-
var req struct {
303-
PairID string `json:"pairId"`
304-
}
305-
if err := c.ShouldBindJSON(&req); err != nil || req.PairID == "" {
306-
c.JSON(http.StatusBadRequest, gin.H{"error": "pairId is required"})
307-
return
308-
}
309-
p, err := relayManager.acceptPair(req.PairID, userID)
310-
if err != nil {
311-
c.JSON(relayErrorStatus(err), gin.H{"error": err.Error()})
312-
return
313-
}
314-
// 通知发起方:已被接受
315-
wsManager.sendToUser(p.UserA, marshalWSMessage("relay:accepted", gin.H{
316-
"pairId": p.PairID, "userId": userID, "status": p.Status,
317-
}))
318-
c.JSON(http.StatusOK, p)
319-
}
320-
321-
// RelayComplete handles POST /api/v1/relay/complete
322-
func RelayComplete(c *gin.Context) {
323-
userID := c.GetString("user_id")
324-
var req struct {
325-
PairID string `json:"pairId"`
326-
FocusMinutes int `json:"focusMinutes"`
327-
}
328-
if err := c.ShouldBindJSON(&req); err != nil || req.PairID == "" {
329-
c.JSON(http.StatusBadRequest, gin.H{"error": "pairId is required"})
330-
return
331-
}
332-
p, err := relayManager.completeTurn(req.PairID, userID, req.FocusMinutes)
333-
if err != nil {
334-
c.JSON(relayErrorStatus(err), gin.H{"error": err.Error()})
335-
return
336-
}
337-
// 通知搭档:本轮完成
338-
if partner, ok := relayManager.partnerOf(p.PairID, userID); ok {
339-
wsManager.sendToUser(partner, marshalWSMessage("relay:turn-complete", gin.H{
340-
"pairId": p.PairID, "userId": userID,
341-
"relayCount": p.RelayCount, "totalFocusMinutes": p.TotalFocusMinutes,
342-
}))
343-
}
344-
c.JSON(http.StatusOK, p)
345-
}
346-
347-
// RelayStats handles GET /api/v1/relay/stats
348-
func RelayStats(c *gin.Context) {
349-
userID := c.GetString("user_id")
350-
stats, pairs := relayManager.userStats(userID)
351-
c.JSON(http.StatusOK, gin.H{
352-
"relayCount": stats.RelayCount,
353-
"totalFocusMinutes": stats.TotalFocusMinutes,
354-
"pairs": pairs,
355-
})
356-
}
357-
358-
// ---------- WebSocket 消息处理(4.2 协议) ----------
359-
360-
// handleRelayWSMessage 处理 "relay:partner-status":客户端上报自己的番茄钟状态,
361-
// 服务器校验其确为配对成员后转发给搭档(状态仅限枚举,不携带内容)。
362-
func handleRelayWSMessage(c *WSConnection, msg WSMessage) {
363-
var payload struct {
364-
PairID string `json:"pairId"`
365-
Status string `json:"status"` // focusing | break | done(与房间成员状态一致)
366-
}
367-
if err := json.Unmarshal(msg.Payload, &payload); err != nil || payload.PairID == "" {
368-
return
369-
}
370-
if !validRoomMemberStatus(payload.Status) {
371-
return
372-
}
373-
partner, ok := relayManager.partnerOf(payload.PairID, c.UserID)
374-
if !ok {
375-
return
376-
}
377-
wsManager.sendToUser(partner, marshalWSMessage("relay:partner-status", gin.H{
378-
"pairId": payload.PairID, "userId": c.UserID, "status": payload.Status,
379-
}))
380-
}

0 commit comments

Comments
 (0)