feat: 离线License批量删除;同步循环加固(启动即跑/pending自动握手/防重入/panic恢复,移至main启动);同步时间显示到秒
This commit is contained in:
1 parent
30b87dec62
commit
252ae14aef
5 files changed
+79
-11
No files matched your search
@@ -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,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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) ============
|
||||||
|
|||||||
@@ -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('')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user