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) { {{ display(row.createdAt) }} + + {{ display(row.lastHeartbeatAt) }} + {{ display(row.remark) }} diff --git a/platform/src/views/cursor/equipment/index.vue b/platform/src/views/cursor/equipment/index.vue index 6d278b9..ac33a5f 100644 --- a/platform/src/views/cursor/equipment/index.vue +++ b/platform/src/views/cursor/equipment/index.vue @@ -97,8 +97,11 @@ const summary = computed(() => { const inactive = tableData.value.filter((item) => item.status === 'inactive').length; const disabled = tableData.value.filter((item) => item.status === 'disabled').length; const expired = tableData.value.filter((item) => item.status === 'expired').length; + const online = tableData.value.filter((item) => item.isOnline).length; + const offline = tableData.value.length - online; return [ { label: '当前页设备', value: tableData.value.length, type: 'primary' }, + { label: '在线 / 离线', isOnlineOffline: true, online, offline }, { label: '已激活设备', value: active, type: 'success' }, { label: '未激活', value: inactive, type: 'info' }, { label: '禁用/过期', value: disabled + expired, type: 'danger' }, @@ -186,6 +189,8 @@ function normalizeRow(raw: any): EquipmentRow { lastExtractedAt: formatTime(pick(raw, 'last_extracted_at', 'lastExtractedAt', 'extracted_at')), expiredAt: formatTime(pick(raw, 'expiredAt', 'expired_at', 'expireTime', 'expire_time')), createdAt: formatTime(pick(raw, 'create_time', 'created_at', 'createdAt', 'CreatedAt')), + isOnline: !!pick(raw, 'isOnline', 'is_online'), + lastHeartbeatAt: formatTime(pick(raw, 'lastHeartbeatAt', 'last_heartbeat_at')), lastLoginIp: pick(raw, 'lastLoginIp', 'last_login_ip') || '', lastLoginIpInfo: raw?.lastLoginIpInfo ?? null, remark: pick(raw, 'remark', 'Remark'), @@ -463,7 +468,12 @@ onUnmounted(() => {
{{ item.label }}
-
{{ item.value }}
+
+ {{ item.online }} + / + {{ item.offline }} +
+
{{ item.value }}
@@ -500,6 +510,13 @@ onUnmounted(() => { > + + +