feat: 离线License批量删除;同步循环加固(启动即跑/pending自动握手/防重入/panic恢复,移至main启动);同步时间显示到秒

This commit is contained in:
Logan.Li committed 2026-09-14 22:08:43 +08:00
1 parent 30b87dec62
commit 252ae14aef
5 files changed
+79 -11

No files matched your search

+31 -5
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"sync/atomic"
"time" "time"
"backend/internal/dto" "backend/internal/dto"
@@ -194,24 +195,49 @@ func formatApplied(m map[string]int) string {
// ============ 定时循环 ============ // ============ 定时循环 ============
// StartSyncLoop 启动宽松节奏的定时双向同步(默认 10 分钟一轮,失败顺延下轮重试) // Logan.Li // StartSyncLoop 启动宽松节奏的定时双向同步(默认 10 分钟一轮)。
// 行为:启动即先跑一轮;active 节点直接交换;pending 节点先自动握手(两侧
// 均已配置同钥时自动激活,无需人工点击);上一轮未结束时本轮跳过(防堆积)。 // Logan.Li
func (s *Service) StartSyncLoop(intervalMinutes int) { func (s *Service) StartSyncLoop(intervalMinutes int) {
if intervalMinutes <= 0 { if intervalMinutes <= 0 {
intervalMinutes = 10 intervalMinutes = 10
} }
go func() { go func() {
ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute) var running int32
defer ticker.Stop() runOnce := func() {
for range ticker.C { if !atomic.CompareAndSwapInt32(&running, 0, 1) {
return // 上一轮仍在进行,跳过本轮防堆积 // Logan.Li
}
defer atomic.StoreInt32(&running, 0)
defer func() {
if r := recover(); r != nil {
log.Printf("[同步] 本轮异常恢复: %v", r)
}
}()
var peers []model.PeerNode var peers []model.PeerNode
s.db.Where("status = ?", model.PeerStatusActive).Find(&peers) s.db.Where("status IN ?", []string{model.PeerStatusActive, model.PeerStatusPending}).Find(&peers)
for _, peer := range peers { for _, peer := range peers {
p := peer p := peer
// pending 先尝试握手(对端未配置/密钥不一致时静默失败,下轮再试) // Logan.Li
if p.Status == model.PeerStatusPending {
if _, err := s.TestHandshake(p.ID); err != nil {
continue
}
s.db.First(&p, p.ID)
}
if _, err := s.ExchangeOut(&p); err != nil { if _, err := s.ExchangeOut(&p); err != nil {
log.Printf("[同步] 节点 %s 本轮失败: %v", peer.Name, err) log.Printf("[同步] 节点 %s 本轮失败: %v", peer.Name, err)
} }
} }
} }
runOnce() // 启动即先跑一轮,不等首个间隔 // Logan.Li
ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute)
defer ticker.Stop()
for range ticker.C {
runOnce()
}
}() }()
log.Printf("[同步] 定时双向同步已启动(每 %d 分钟)", intervalMinutes) log.Printf("[同步] 定时双向同步已启动(每 %d 分钟)", intervalMinutes)
} }
+4
View File
@@ -4,6 +4,7 @@ import (
"backend/config" "backend/config"
"backend/database" "backend/database"
licenseservice "backend/internal/service/license" licenseservice "backend/internal/service/license"
syncservice "backend/internal/service/sync"
"backend/router" "backend/router"
"context" "context"
"log" "log"
@@ -27,6 +28,9 @@ func main() {
// 启动在线状态清扫(超时未心跳的激活记录置为离线) // Logan.Li // 启动在线状态清扫(超时未心跳的激活记录置为离线) // Logan.Li
licenseservice.StartOnlineSweeper(db, cfg.OnlineTTLMinutes) licenseservice.StartOnlineSweeper(db, cfg.OnlineTTLMinutes)
// 启动节点定时双向同步(宽松节奏,默认 10 分钟) // Logan.Li
syncservice.NewService(db, cfg).StartSyncLoop(cfg.SyncIntervalMinutes)
// 初始化路由 // Logan.Li // 初始化路由 // Logan.Li
r := router.SetupRouter(db, cfg) r := router.SetupRouter(db, cfg)
-3
View File
@@ -96,9 +96,6 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
settingController := setting.NewSettingController(db) settingController := setting.NewSettingController(db)
syncController := synccontroller.NewController(db, cfg) syncController := synccontroller.NewController(db, cfg)
// 启动节点定时双向同步(宽松节奏,默认 10 分钟) // Logan.Li
syncController.Svc().StartSyncLoop(cfg.SyncIntervalMinutes)
api := r.Group("/api") api := r.Group("/api")
{ {
// ============ 节点互联(对端调用,HMAC 签名认证,非 JWT) ============ // ============ 节点互联(对端调用,HMAC 签名认证,非 JWT) ============
+32 -2
View File
@@ -9,6 +9,9 @@
</el-select> </el-select>
</div> </div>
<div> <div>
<el-button type="danger" plain :disabled="!selectedRows.length" @click="batchDelete">
批量删除{{ selectedRows.length ? `(${selectedRows.length})` : '' }}
</el-button>
<el-button type="primary" @click="openIssue">签发离线序列号</el-button> <el-button type="primary" @click="openIssue">签发离线序列号</el-button>
</div> </div>
</div> </div>
@@ -21,7 +24,8 @@
style="margin-bottom: 12px" style="margin-bottom: 12px"
/> />
<el-table :data="list" v-loading="loading" stripe> <el-table :data="list" v-loading="loading" stripe @selection-change="onSelectionChange">
<el-table-column type="selection" width="48" />
<el-table-column label="序列号" min-width="200"> <el-table-column label="序列号" min-width="200">
<template #default="{ row }"> <template #default="{ row }">
<div class="serial-cell"> <div class="serial-cell">
@@ -112,9 +116,35 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getLicenses, issueOfflineLicense, revokeLicense } from '@/api/license' import { getLicenses, issueOfflineLicense, revokeLicense, batchDeleteLicenses } from '@/api/license'
const list = ref([]) const list = ref([])
const selectedRows = ref([])
// 仅已吊销 / 已过期可删除(与在线 License 一致) // Logan.Li
function canDelete(row) {
return row.status === 'revoked' || row.status === 'expired' || row.days_left <= 0
}
function onSelectionChange(rows) {
selectedRows.value = rows || []
}
async function batchDelete() {
const ids = selectedRows.value.map((r) => r.id).filter(Boolean)
if (!ids.length) return
const deletable = selectedRows.value.filter(canDelete).length
await ElMessageBox.confirm(
`已选 ${ids.length} 条,其中约 ${deletable} 条可删(仅已吊销/已过期);其余将跳过。确认继续?`,
'批量删除',
{ type: 'warning' },
)
const res = await batchDeleteLicenses(ids)
const data = res.data || {}
ElMessage.success(`删除 ${data.deleted || 0} 条,跳过 ${data.skipped || 0} 条${data.failed ? `,失败 ${data.failed}` : ''}`)
selectedRows.value = []
loadData()
}
const loading = ref(false) const loading = ref(false)
const keyword = ref('') const keyword = ref('')
const statusFilter = ref('') const statusFilter = ref('')
+12 -1
View File
@@ -22,7 +22,9 @@
<el-table-column label="密钥尾号" width="90"> <el-table-column label="密钥尾号" width="90">
<template #default="{ row }"><code>…{{ row.key_tail }}</code></template> <template #default="{ row }"><code>…{{ row.key_tail }}</code></template>
</el-table-column> </el-table-column>
<el-table-column prop="last_sync_at" label="上次同步" width="160" /> <el-table-column label="上次同步" width="160">
<template #default="{ row }">{{ formatTime(row.last_sync_at) }}</template>
</el-table-column>
<el-table-column prop="last_result" label="最近结果" min-width="200" show-overflow-tooltip /> <el-table-column prop="last_result" label="最近结果" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="240" fixed="right"> <el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
@@ -129,6 +131,15 @@ async function doDelete(row) {
await load() await load()
} }
// formatTime 时间展示精确到秒(后端返回带纳秒的 RFC3339) // Logan.Li
function formatTime(v) {
if (!v) return '—'
const d = new Date(v)
if (Number.isNaN(d.getTime())) return v
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
onMounted(load) onMounted(load)
</script> </script>