From b103192fac6f3cef6b4768eec947de8be378af21 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com>
Date: Mon, 22 Jun 2026 12:03:58 +0800
Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=99=BB=E5=BD=95=E5=99=A8?=
=?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=BF=83=E8=B7=B3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
go/controllers/api_cursor_equipment.go | 81 ++++++
go/controllers/platform_cursor_equipment.go | 244 +++++++++++++++++-
go/models/platform_cursor_equipment.go | 1 +
go/routers/api/api.go | 3 +
.../cursor/equipment/components/detail.vue | 3 +
platform/src/views/cursor/equipment/index.vue | 45 +++-
sql/alter_equipment_add_heartbeat.sql | 8 +
7 files changed, 382 insertions(+), 3 deletions(-)
create mode 100644 sql/alter_equipment_add_heartbeat.sql
diff --git a/go/controllers/api_cursor_equipment.go b/go/controllers/api_cursor_equipment.go
index a5a89c4..1c63c31 100644
--- a/go/controllers/api_cursor_equipment.go
+++ b/go/controllers/api_cursor_equipment.go
@@ -637,3 +637,84 @@ func (c *ApiCursorEquipmentController) ActivateByCode() {
"expiredAt": expireTime,
})
}
+
+type cursorHeartbeatPayload struct {
+ MachineCode string `json:"machineCode"`
+ MachineCodeSnake string `json:"machine_code"`
+}
+
+// Heartbeat POST /api/cursor/equipment/heartbeat
+//
+// 客户端心跳接口(无需登录),用于上报在线状态。
+//
+// JSON 示例:
+//
+// {
+// "machineCode": "ABC-123"
+// }
+func (c *ApiCursorEquipmentController) Heartbeat() {
+ var p cursorHeartbeatPayload
+ body := c.Ctx.Input.RequestBody
+ if len(body) > 0 {
+ if err := json.Unmarshal(body, &p); err != nil {
+ c.jsonResult(400, "参数错误", nil)
+ return
+ }
+ }
+
+ machineCode := cursorFirstNonEmpty(p.MachineCode, p.MachineCodeSnake)
+ if machineCode == "" {
+ machineCode = c.GetString("machineCode")
+ }
+ if machineCode == "" {
+ machineCode = c.GetString("machine_code")
+ }
+
+ if machineCode == "" {
+ c.jsonResult(400, "缺少参数 machineCode/machine_code(机器码)", nil)
+ return
+ }
+
+ now := time.Now()
+ // 查询设备是否存在
+ var row models.PlatformCursorEquipment
+ err := models.Orm.QueryTable(new(models.PlatformCursorEquipment)).
+ Filter("machine_code", machineCode).
+ Filter("delete_time__isnull", true).
+ One(&row)
+
+ if err == orm.ErrNoRows {
+ // 设备不存在,可能是第一次运行心跳,也可以允许在此处静默创建,或者返回 404 让客户端先进行 report
+ // 为了鲁棒性,如果设备未上报过,我们可以直接创建一个基础设备记录
+ row = models.PlatformCursorEquipment{
+ MachineCode: machineCode,
+ Status: 0, // 未激活
+ LastHeartbeatAt: &now,
+ CreateTime: now,
+ }
+ if _, insertErr := models.Orm.Insert(&row); insertErr != nil {
+ c.jsonResult(500, "保存设备心跳失败", nil)
+ return
+ }
+ } else if err != nil {
+ c.jsonResult(500, "设备查询失败", nil)
+ return
+ } else {
+ // 更新最后心跳时间
+ if _, updateErr := models.Orm.QueryTable(new(models.PlatformCursorEquipment)).
+ Filter("id", row.ID).
+ Update(map[string]interface{}{
+ "last_heartbeat_at": &now,
+ "update_time": now,
+ }); updateErr != nil {
+ c.jsonResult(500, "更新设备心跳失败", nil)
+ return
+ }
+ }
+
+ c.jsonResult(200, "success", map[string]interface{}{
+ "machineCode": machineCode,
+ "online": true,
+ })
+}
+
diff --git a/go/controllers/platform_cursor_equipment.go b/go/controllers/platform_cursor_equipment.go
index 764dd92..ddc9e29 100644
--- a/go/controllers/platform_cursor_equipment.go
+++ b/go/controllers/platform_cursor_equipment.go
@@ -178,11 +178,19 @@ func (c *PlatformCursorEquipmentController) rowToMap(row *models.PlatformCursorE
}
}
+ // 在线状态计算:若最后心跳时间在 5 分钟内,则认为在线
+ isOnline := false
+ if row.LastHeartbeatAt != nil && time.Since(*row.LastHeartbeatAt) < 5*time.Minute {
+ isOnline = true
+ }
+
return map[string]interface{}{
"id": row.ID,
"deviceInfo": row.DeviceInfo,
"machineCode": row.MachineCode,
"status": row.Status,
+ "isOnline": isOnline,
+ "lastHeartbeatAt": row.LastHeartbeatAt,
"system": row.System,
"os": row.System,
"version": row.Version,
@@ -208,6 +216,7 @@ func (c *PlatformCursorEquipmentController) rowToMap(row *models.PlatformCursorE
}
// List GET /platform/cursor/equipment/list
+// 优化:用 3 条批量 SQL 替代 N+1,列表只返回必要字段,详情由 Detail 接口按需加载完整数据。
func (c *PlatformCursorEquipmentController) List() {
if _, err := c.platformClaims(); err != nil {
c.jsonErr(401, 401, err.Error())
@@ -265,9 +274,242 @@ func (c *PlatformCursorEquipmentController) List() {
return
}
+ if len(rows) == 0 {
+ c.ok(map[string]interface{}{
+ "list": []interface{}{},
+ "total": total,
+ "page": page,
+ "pageSize": pageSize,
+ })
+ return
+ }
+
+ // ── 收集本页所有设备的 ID 和机器码 ──────────────────────────────────
+ deviceIDs := make([]uint64, 0, len(rows))
+ machineCodes := make([]string, 0, len(rows))
+ codeToID := make(map[string]uint64, len(rows)) // machineCode -> equipmentID
+ for _, r := range rows {
+ deviceIDs = append(deviceIDs, r.ID)
+ machineCodes = append(machineCodes, r.MachineCode)
+ codeToID[r.MachineCode] = r.ID
+ }
+
+ // ── 批量查激活码(1 条 SQL)──────────────────────────────────────────
+ // 只取本页设备相关的所有激活码,按 id 降序,在内存里取每个设备的 latest
+ type actSummary struct {
+ code string
+ codeID uint64
+ count int64
+ lastActivatedAt interface{}
+ expiredAt interface{}
+ }
+ actMap := make(map[uint64]*actSummary, len(rows))
+
+ var allActs []models.PlatformCursorActivationCode
+ if len(deviceIDs) > 0 {
+ // Beego ORM 的 __in 过滤
+ ids := make([]interface{}, len(deviceIDs))
+ for i, id := range deviceIDs {
+ ids[i] = id
+ }
+ codes := make([]interface{}, len(machineCodes))
+ for i, mc := range machineCodes {
+ codes[i] = mc
+ }
+ actCond := orm.NewCondition().
+ And("delete_time__isnull", true).
+ AndCond(orm.NewCondition().
+ Or("bind_device_id__in", ids).
+ Or("machine_code__in", codes))
+ models.Orm.QueryTable(new(models.PlatformCursorActivationCode)).
+ SetCond(actCond).
+ OrderBy("-activated_at", "-id").
+ All(&allActs)
+ }
+ for i := range allActs {
+ a := &allActs[i]
+ // 确定归属设备 ID
+ var devID uint64
+ if a.BindDeviceID != nil && *a.BindDeviceID != 0 {
+ devID = *a.BindDeviceID
+ } else if a.MachineCode != nil {
+ devID = codeToID[*a.MachineCode]
+ }
+ if devID == 0 {
+ continue
+ }
+ s, exists := actMap[devID]
+ if !exists {
+ s = &actSummary{}
+ actMap[devID] = s
+ }
+ s.count++
+ // 第一条就是 latest(已按 -activated_at,-id 排序)
+ if s.codeID == 0 {
+ s.code = a.Code
+ s.codeID = a.ID
+ if a.ActivatedAt != nil {
+ s.lastActivatedAt = a.ActivatedAt
+ }
+ if a.ExpiredAt != nil {
+ s.expiredAt = a.ExpiredAt
+ }
+ }
+ }
+
+ // ── 批量查提取记录(1 条 SQL)──────────────────────────────────────────
+ type extractSummary struct {
+ count int64
+ lastExtracted interface{}
+ }
+ extractMap := make(map[uint64]*extractSummary, len(rows))
+
+ type extractRow struct {
+ MachineCode string
+ Cnt int64
+ LastExtracted *string
+ }
+ var extractRows []extractRow
+ if len(machineCodes) > 0 {
+ inPlaceholders := make([]string, len(machineCodes))
+ inArgs := make([]interface{}, len(machineCodes))
+ for i, mc := range machineCodes {
+ inPlaceholders[i] = "?"
+ inArgs[i] = mc
+ }
+ sql := "SELECT machine_code, COUNT(*) AS cnt, MAX(extracted_time) AS last_extracted " +
+ "FROM yz_platform_account_pool_cursor " +
+ "WHERE is_extracted > 0 AND delete_time IS NULL " +
+ "AND machine_code IN (" + strings.Join(inPlaceholders, ",") + ") " +
+ "GROUP BY machine_code"
+ models.Orm.Raw(sql, inArgs...).QueryRows(&extractRows)
+ }
+ for _, er := range extractRows {
+ devID := codeToID[er.MachineCode]
+ if devID == 0 {
+ continue
+ }
+ es := &extractSummary{count: er.Cnt}
+ if er.LastExtracted != nil {
+ es.lastExtracted = *er.LastExtracted
+ }
+ extractMap[devID] = es
+ }
+
+ // ── 批量查最后登录 IP(1 条 SQL)────────────────────────────────────────
+ // 每个设备取 id 最大的一条
+ type ipRow struct {
+ EquipmentID uint64
+ MachineCode string
+ Query string
+ }
+ ipMap := make(map[uint64]string, len(rows))
+ if len(deviceIDs) > 0 {
+ inPlaceholders := make([]string, len(deviceIDs))
+ inArgs := make([]interface{}, len(deviceIDs))
+ for i, id := range deviceIDs {
+ inPlaceholders[i] = "?"
+ inArgs[i] = id
+ }
+ codePlaceholders := make([]string, len(machineCodes))
+ codeArgs := make([]interface{}, len(machineCodes))
+ for i, mc := range machineCodes {
+ codePlaceholders[i] = "?"
+ codeArgs[i] = mc
+ }
+ allArgs := append(inArgs, codeArgs...)
+ ipSQL := "SELECT equipment_id, machine_code, query FROM yz_platform_cursor_equipment_ip_log " +
+ "WHERE id IN (" +
+ " SELECT MAX(id) FROM yz_platform_cursor_equipment_ip_log " +
+ " WHERE equipment_id IN (" + strings.Join(inPlaceholders, ",") + ")" +
+ " OR machine_code IN (" + strings.Join(codePlaceholders, ",") + ")" +
+ " GROUP BY COALESCE(NULLIF(equipment_id,0), machine_code)" +
+ ")"
+ var ipRows []ipRow
+ models.Orm.Raw(ipSQL, allArgs...).QueryRows(&ipRows)
+ for _, ir := range ipRows {
+ devID := ir.EquipmentID
+ if devID == 0 {
+ devID = codeToID[ir.MachineCode]
+ }
+ if devID != 0 {
+ ipMap[devID] = ir.Query
+ }
+ }
+ }
+
+ // ── 组装列表(纯内存操作)────────────────────────────────────────────
list := make([]map[string]interface{}, 0, len(rows))
for i := range rows {
- list = append(list, c.rowToMap(&rows[i]))
+ r := &rows[i]
+
+ // 激活码信息
+ var bindActivationCode interface{}
+ var activationCodeId interface{}
+ var lastActivatedAt interface{} = r.ActivationTime
+ var expiredAt interface{} = r.ExpireTime
+ var activationCount int64
+ if s, ok := actMap[r.ID]; ok {
+ activationCount = s.count
+ bindActivationCode = s.code
+ activationCodeId = s.codeID
+ if s.lastActivatedAt != nil {
+ lastActivatedAt = s.lastActivatedAt
+ }
+ if s.expiredAt != nil {
+ expiredAt = s.expiredAt
+ }
+ }
+
+ // 提取记录
+ var extractCount int64
+ var lastExtractedAt interface{}
+ if es, ok := extractMap[r.ID]; ok {
+ extractCount = es.count
+ lastExtractedAt = es.lastExtracted
+ }
+
+ // 最后登录 IP
+ var lastLoginIp interface{}
+ if ip, ok := ipMap[r.ID]; ok && ip != "" {
+ lastLoginIp = ip
+ }
+
+ // 在线状态计算:若最后心跳时间在 5 分钟内,则认为在线
+ isOnline := false
+ if r.LastHeartbeatAt != nil && time.Since(*r.LastHeartbeatAt) < 5*time.Minute {
+ isOnline = true
+ }
+
+ list = append(list, map[string]interface{}{
+ "id": r.ID,
+ "deviceInfo": r.DeviceInfo,
+ "machineCode": r.MachineCode,
+ "status": r.Status,
+ "isOnline": isOnline,
+ "lastHeartbeatAt": r.LastHeartbeatAt,
+ "system": r.System,
+ "os": r.System,
+ "version": r.Version,
+ "bindAccount": r.BindAccount,
+ "bindActivationCode": bindActivationCode,
+ "activationCode": bindActivationCode,
+ "activationCodeId": activationCodeId,
+ "ownerUserId": r.OwnerUserID,
+ "ownerUserName": r.OwnerUserName,
+ "activationTime": lastActivatedAt,
+ "lastActivatedAt": lastActivatedAt,
+ "expireTime": expiredAt,
+ "expiredAt": expiredAt,
+ "activationCount": activationCount,
+ "extractCount": extractCount,
+ "lastExtractedAt": lastExtractedAt,
+ "lastLoginIp": lastLoginIp,
+ // 列表不返回 lastLoginIpInfo,点详情时由 Detail 接口加载完整 IP 信息
+ "remark": r.Remark,
+ "createTime": r.CreateTime,
+ "updateTime": r.UpdateTime,
+ })
}
c.ok(map[string]interface{}{
diff --git a/go/models/platform_cursor_equipment.go b/go/models/platform_cursor_equipment.go
index a8d4289..9b9e425 100644
--- a/go/models/platform_cursor_equipment.go
+++ b/go/models/platform_cursor_equipment.go
@@ -16,6 +16,7 @@ type PlatformCursorEquipment struct {
ActivationTime *time.Time `orm:"column(activation_time);type(datetime);null" json:"activationTime"`
ExpireTime *time.Time `orm:"column(expire_time);type(datetime);null" json:"expireTime"`
Remark *string `orm:"column(remark);size(1000);null" json:"remark"`
+ LastHeartbeatAt *time.Time `orm:"column(last_heartbeat_at);type(datetime);null" json:"lastHeartbeatAt"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"createTime"`
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"updateTime"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"deleteTime"`
diff --git a/go/routers/api/api.go b/go/routers/api/api.go
index 1281df9..b1ae4a0 100644
--- a/go/routers/api/api.go
+++ b/go/routers/api/api.go
@@ -17,6 +17,9 @@ func Register() {
// 登录器使用激活码激活/续期 Cursor 设备(无需登录)
beego.Router("/api/cursor/equipment/activateByCode", &controllers.ApiCursorEquipmentController{}, "post:ActivateByCode")
+ // 登录器心跳接口,用于更新在线状态(无需登录)
+ beego.Router("/api/cursor/equipment/heartbeat", &controllers.ApiCursorEquipmentController{}, "post:Heartbeat")
+
// Cursor Token 顺序读取/检测接口(无需登录,peek 不改变号池状态)
// GET /api/cursor/token/peek?id=11&data_type=tk
beego.Router("/api/cursor/token/peek", &controllers.ApiCursorDetectController{}, "get:PeekToken")
diff --git a/platform/src/views/cursor/equipment/components/detail.vue b/platform/src/views/cursor/equipment/components/detail.vue
index e73f30e..98e3ea8 100644
--- a/platform/src/views/cursor/equipment/components/detail.vue
+++ b/platform/src/views/cursor/equipment/components/detail.vue
@@ -112,6 +112,9 @@ function sourceLabel(source: string) {