|
1 | 1 | // @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 | +// 仅做文件切分,零行为改变。 |
5 | 8 | package handlers |
6 | 9 |
|
7 | 10 | import ( |
8 | | - "encoding/json" |
9 | 11 | "errors" |
10 | 12 | "log" |
11 | 13 | "net/http" |
12 | 14 | "strconv" |
13 | 15 | "sync" |
14 | 16 | "time" |
15 | | - |
16 | | - "github.com/gin-gonic/gin" |
17 | 17 | ) |
18 | 18 |
|
19 | 19 | // RelayPair 一条接力配对关系(waiting:待对方接受;running:接力进行中)。 |
@@ -241,140 +241,3 @@ func relayErrorStatus(err error) int { |
241 | 241 | return http.StatusBadRequest |
242 | 242 | } |
243 | 243 | } |
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