package handlers import ( "crypto/rand" "errors" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "everypublish/server/internal/api/response" "everypublish/server/internal/models" "everypublish/server/internal/ws" ) // AgentHandler 设备配对与列表 type AgentHandler struct { DB *gorm.DB Hub *ws.Hub } const pairAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" var errPairCodeUsed = errors.New("pair code already used") func randCode(n int) string { b := make([]byte, n) _, _ = rand.Read(b) for i := range b { b[i] = pairAlphabet[int(b[i])%len(pairAlphabet)] } return string(b) } // PairCode 生成配对码(5 分钟一次性) func (h *AgentHandler) PairCode(c *gin.Context) { pc := models.PairingCode{ WorkspaceID: c.GetUint64("wsid"), Code: randCode(6), ExpiresAt: time.Now().Add(5 * time.Minute), } if err := h.DB.Create(&pc).Error; err != nil { response.Fail(c, http.StatusInternalServerError, 5000, "生成配对码失败") return } response.Audit(c, "agent.pair_code", "pairing:"+itoa(pc.ID), gin.H{"code": pc.Code}) response.OK(c, gin.H{"code": pc.Code, "expiresAt": pc.ExpiresAt}) } // Pair 设备配对(无鉴权,凭一次性码;注册 Ed25519 公钥) func (h *AgentHandler) Pair(c *gin.Context) { var req struct { Code string `json:"code" binding:"required"` DeviceName string `json:"deviceName" binding:"required"` OS string `json:"os"` Version string `json:"version"` PublicKey string `json:"publicKey" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { response.Fail(c, http.StatusBadRequest, 1001, "参数不合法") return } var pc models.PairingCode if err := h.DB.Where("code = ? and used = ?", req.Code, false).First(&pc).Error; err != nil { response.Fail(c, http.StatusBadRequest, 2006, "配对码无效") return } if time.Now().After(pc.ExpiresAt) { response.Fail(c, http.StatusGone, 2006, "配对码已过期") return } device := models.AgentDevice{ WorkspaceID: pc.WorkspaceID, Name: req.DeviceName, OS: req.OS, Version: req.Version, PublicKey: req.PublicKey, Status: "offline", PairedAt: time.Now(), } err := h.DB.Transaction(func(tx *gorm.DB) error { // 原子占用:仅当仍 unused 且未过期时置 used=true;RowsAffected==1 才视为抢到, // 防止并发用同一码配出多台设备。 res := tx.Model(&models.PairingCode{}). Where("id = ? and used = ?", pc.ID, false). Update("used", true) if res.Error != nil { return res.Error } if res.RowsAffected != 1 { return errPairCodeUsed } if err := tx.Create(&device).Error; err != nil { return err } if err := tx.Model(&models.PairingCode{}).Where("id = ?", pc.ID). Update("device_id", device.ID).Error; err != nil { return err } return nil }) if err != nil { if err == errPairCodeUsed { response.Fail(c, http.StatusBadRequest, 2006, "配对码已被使用") return } response.Fail(c, http.StatusInternalServerError, 5000, "配对失败") return } response.OK(c, gin.H{"deviceId": device.ID, "workspaceId": device.WorkspaceID}) } // Devices 设备列表(在线状态来自 Hub,此处以 DB 状态兜底) func (h *AgentHandler) Devices(c *gin.Context) { var list []models.AgentDevice h.DB.Where("workspace_id = ?", c.GetUint64("wsid")).Order("id desc").Find(&list) response.OK(c, gin.H{"list": list}) } // Revoke 吊销设备并强制断开(owner/admin) func (h *AgentHandler) Revoke(c *gin.Context) { if !isAdminRole(c.GetString("mrole")) { response.Fail(c, http.StatusForbidden, 1003, "仅管理员可吊销设备") return } id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { response.Fail(c, http.StatusBadRequest, 1001, "参数不合法") return } var device models.AgentDevice if err = h.DB.Where("id = ? and workspace_id = ?", id, c.GetUint64("wsid")).First(&device).Error; err != nil { response.Fail(c, http.StatusNotFound, 1004, "设备不存在") return } if err = h.DB.Model(&device).Updates(map[string]interface{}{"revoked": true, "status": "offline"}).Error; err != nil { response.Fail(c, http.StatusInternalServerError, 5000, "吊销失败") return } if h.Hub != nil { h.Hub.KickAgent(device.ID) } response.Audit(c, "agent.revoke", "device:"+itoa(id), nil) response.OK(c, nil) }