// device_register.go — POST /api/devices/:id/register 與 /unregister 的 handler。 // // 「註冊」語意軸(feature-device-mgmt-tdd §3 / §4,api/api-device-mgmt.md): // - register:把 device 的 registered_at 由 NULL 翻成 now()(未註冊 → 已註冊)。 // - unregister:把 registered_at 清成 NULL(退回未註冊),**保留裝置列**。 // // 🔴 與 unpair 完全不同(TDD §1 紅線):unpair 軟刪整台 + cascade 撤 token(device 從清單 // 消失);unregister 只清單欄 registered_at(device 仍在清單、顯示為未註冊)。兩端點各走各的, // 本檔**絕不呼叫** DeviceUnpairer / Delete / 撤 token,也**不改** devicesUnpairHandler。 // // 皆為純雲端 DB 操作(只翻 registered_at、不路由 local agent),用 UUID `:id` 識別 // (對齊 ADR-018 FE-A:DB 操作用 UUID、路由操作才用 serial)。 package api import ( "context" "errors" "net/http" "time" "github.com/gin-gonic/gin" "visiona-backend/internal/device" ) // deviceRegisterCommon 執行 register / unregister 共用的前置檢查(步驟 1-5,兩端點一致): // // 1. 缺 UserContext → 500(auth middleware 沒配好,不可 fallthrough) // 2. :id 空 → 400 VALIDATION_FAILED // 3. Get device:ErrNotFound → 404;其他 DB error → WriteDBError // 4. owner 檢查(IDOR 主防線):d.OwnerUserID != userID → 403 FORBIDDEN // 5. representative 檢查:d.IsRepresentative → 409 REPRESENTATIVE_DEVICE // // 回傳 (device, userID, ok);ok=false 時已寫好回應,caller 直接 return。 // // owner 檢查對 register/unregister 都必做——不能因「只是翻 flag」省略(TDD §7.2 IDOR)。 func deviceRegisterCommon(c *gin.Context, deps Deps, ctx context.Context) (*device.Device, string, bool) { id := c.Param("id") if id == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device id required", nil) return nil, "", false } // Phase 0.7 security fix C1:強制要求 UserContext 非空(見既有 devices.go 範式)。 uc, ok := UserContextFrom(c) if !ok || uc.UserID == "" { WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "missing user context (auth middleware misconfigured?)", nil) return nil, "", false } userID := uc.UserID d, err := deps.DeviceRepo.Get(ctx, id) if err != nil { if errors.Is(err, device.ErrNotFound) { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) return nil, "", false } // DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 WriteDBError(c, deps.Logger, "get device", err) return nil, "", false } // owner 檢查(IDOR 主防線,TDD §7.1/§7.2):沿用既有 handler 慣例(devices.go:197-201)。 if d.OwnerUserID != userID { WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner of this device", nil) return nil, "", false } // representative 檢查(TDD §7.3):representative 是 agent 連線佔位、非真 USB, // 註冊語意不適用。縱深——即使 List 已濾掉 representative(前端拿不到其 UUID), // handler 仍自己擋;repo SetRegistered 的 WHERE 帶 is_representative=false 為第三層。 if d.IsRepresentative { WriteError(c, http.StatusConflict, ErrCodeRepresentativeDevice, "representative device cannot be registered", nil) return nil, "", false } return d, userID, true } // devicesRegisterHandler 實作 POST /api/devices/:id/register。 // // 行為順序(api-device-mgmt.md §1):共用前置(1-5)→ 已註冊檢查(6,409 ALREADY_REGISTERED) // → SetRegistered(now())(7)→ 200 + 更新後 DeviceListItem(registered_at 非 null)。 func devicesRegisterHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.DeviceRepo == nil { WriteNotImplemented(c, "device repo not configured") return } ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() d, userID, ok := deviceRegisterCommon(c, deps, ctx) if !ok { return } // 已註冊檢查(TDD §3.2):registered_at 非 nil → 409 ALREADY_REGISTERED。 // 前端據此顯示「此裝置已註冊」並 refetch。 if d.RegisteredAt != nil { WriteError(c, http.StatusConflict, ErrCodeAlreadyRegistered, "device already registered", nil) return } now := time.Now().UTC() if err := deps.DeviceRepo.SetRegistered(ctx, d.ID, &now); err != nil { if errors.Is(err, device.ErrNotFound) { // 競態:Get 之後、SetRegistered 之前 device 被軟刪 / 轉 representative。 WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) return } WriteDBError(c, deps.Logger, "register device", err) return } logOrDefault(deps.Logger).Info("devices: registered", "device_id", d.ID, "user_id", userID, "request_id", RequestIDFrom(c)) writeDeviceItemAfterRegister(c, deps, ctx, d.ID, userID) } } // devicesUnregisterHandler 實作 POST /api/devices/:id/unregister(退回未註冊)。 // // 行為順序(api-device-mgmt.md §2):共用前置(1-5)→ SetRegistered(nil)(冪等,未註冊也回 200) // → 200 + 更新後 DeviceListItem(registered_at=null)。 // // 🔴 絕不軟刪、不呼叫 DeviceUnpairer、不撤 token、不動 session(TDD §1.2)。與 unpair 各走各的。 func devicesUnregisterHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { if deps.DeviceRepo == nil { WriteNotImplemented(c, "device repo not configured") return } ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() d, userID, ok := deviceRegisterCommon(c, deps, ctx) if !ok { return } // 冪等(TDD §4.1 步驟 2):不做「已註冊才可取消」的硬擋。SetRegistered(nil) 對已 NULL // 的列 UPDATE 到相同值、RowsAffected 仍為 1(WHERE 命中),避免使用者連點兩次第二次報錯。 if err := deps.DeviceRepo.SetRegistered(ctx, d.ID, nil); err != nil { if errors.Is(err, device.ErrNotFound) { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) return } WriteDBError(c, deps.Logger, "unregister device", err) return } logOrDefault(deps.Logger).Info("devices: unregistered", "device_id", d.ID, "user_id", userID, "request_id", RequestIDFrom(c)) writeDeviceItemAfterRegister(c, deps, ctx, d.ID, userID) } } // writeDeviceItemAfterRegister 重新 Get device 並回 200 + 更新後 DeviceListItem。 // // 為什麼重新 Get 而非就地拼裝:SetRegistered 只回 error,最新的 registered_at / updated_at // 以 DB 為準最不易出錯(避免手動拼裝與 DB 值漂移)。合併 tunnel 狀態沿用既有 list/get 範式。 // // register/unregister 後 device 必然存在(剛剛才 UPDATE 成功),Get 理論上不會 NotFound; // 若極端競態下被刪,回 404(不 panic)。 func writeDeviceItemAfterRegister(c *gin.Context, deps Deps, ctx context.Context, id, userID string) { d, err := deps.DeviceRepo.Get(ctx, id) if err != nil { if errors.Is(err, device.ErrNotFound) { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) return } WriteDBError(c, deps.Logger, "get device after register", err) return } // tunnel 狀態合併:獨立 ctx 給 3s 預算(對齊 list/get,避免被前面 DB 呼叫吃掉 → R-3 誤判)。 tunnelCtx, tunnelCancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer tunnelCancel() tunnelAlive, lastSeen := resolveTunnelStatus( tunnelCtx, deps.SessionStore, userID, deps.Logger, "register", RequestIDFrom(c)) item := DeviceListItem{ ID: d.ID, Name: d.Name, DeviceType: d.DeviceType, SerialNumber: d.SerialNumber, AgentID: d.AgentID, RegisteredAt: d.RegisteredAt, RemoteStatus: d.RemoteStatus, LastSeenAt: d.LastSeenAt, LastConnectedAt: d.LastConnectedAt, USBStatus: d.Status, TunnelOnline: tunnelAlive, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, } if item.LastSeenAt == nil && tunnelAlive && !lastSeen.IsZero() { ls := lastSeen item.LastSeenAt = &ls } WriteSuccess(c, http.StatusOK, item) }