feat: 节点互联双向同步——授权/计划/客户(邮箱)按业务键LWW增量同步,HTTPS+HMAC互联密钥,默认10分钟宽松节奏

This commit is contained in:
Logan.Li committed 2026-09-14 21:33:00 +08:00
1 parent 06a9dac99b
commit 44387835e1
13 files changed
+1180 -1

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
/**
* @file sync.js
* @description 节点互联(双向授权同步)管理 API // Logan.Li
*/
import request from './request'
export function listPeers() {
return request.get('/sync/peers')
}
export function savePeer(data) {
return request.post('/sync/peers', data)
}
export function deletePeer(id) {
return request.delete(`/sync/peers/${id}`)
}
export function handshakePeer(id) {
return request.post(`/sync/peers/${id}/handshake`)
}
export function syncPeerNow(id) {
return request.post(`/sync/peers/${id}/sync`)
}
export function generateKey() {
return request.get('/sync/generate-key')
}
+5
View File
@@ -56,6 +56,11 @@
<template #title>支付</template>
</el-menu-item>
<el-menu-item index="/sync">
<el-icon><Connection /></el-icon>
<template #title>节点同步</template>
</el-menu-item>
<el-sub-menu index="system">
<template #title>
<el-icon><TrendCharts /></el-icon>
+6
View File
@@ -71,6 +71,12 @@ const routes = [
component: () => import('@/views/Logs.vue'),
meta: { title: '操作日志', icon: 'Document' },
},
{
path: 'sync',
name: 'Sync',
component: () => import('@/views/Sync.vue'),
meta: { title: '节点同步', icon: 'Connection' },
},
{
path: 'integration',
name: 'Integration',
+141
View File
@@ -0,0 +1,141 @@
<template>
<div>
<el-card shadow="never">
<div class="toolbar">
<span class="node-info">本机节点 ID:<code>{{ nodeId }}</code></span>
<el-button type="primary" @click="openCreate">添加互联节点</el-button>
</div>
<el-alert type="info" :closable="false" class="mb"
title="双向同步:产品 / 计划 / 客户(含邮箱)/ 授权。两边各配置对方地址与相同互联密钥,握手激活后每 10 分钟自动同步一轮,也可手动触发。通道 = HTTPS + 互联密钥 HMAC 签名,手机/地址/设备 IP 不同步。" />
<el-table :data="peers" v-loading="loading" stripe>
<el-table-column prop="name" label="节点" min-width="120" />
<el-table-column prop="base_url" label="对端地址" min-width="220" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : row.status === 'disabled' ? 'info' : 'warning'" size="small">
{{ { active: '已激活', pending: '待握手', disabled: '已停用' }[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="密钥尾号" width="90">
<template #default="{ row }"><code>…{{ row.key_tail }}</code></template>
</el-table-column>
<el-table-column prop="last_sync_at" label="上次同步" width="160" />
<el-table-column prop="last_result" label="最近结果" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="240" fixed="right">
<template #default="{ row }">
<el-button size="small" type="success" @click="doSync(row)">立即同步</el-button>
<el-button size="small" @click="doHandshake(row)">测试握手</el-button>
<el-button size="small" type="danger" plain @click="doDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑节点' : '添加互联节点'" width="560px">
<el-form :model="form" label-width="100px">
<el-form-item label="节点名称">
<el-input v-model="form.name" placeholder="如:香港节点" />
</el-form-item>
<el-form-item label="对端地址">
<el-input v-model="form.base_url" placeholder="https://l123.91ai.io(自动补 /api)" />
</el-form-item>
<el-form-item label="互联密钥">
<el-input v-model="form.shared_key" placeholder="32 位以上 hex,两边一致" show-password>
<template #append>
<el-button @click="genKey">生成</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item>
<span class="tip">两台服务器都要添加对方,并使用同一把互联密钥;任一侧配置后点「测试握手」完成确认。</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
// 节点互联管理页:双向授权同步的 peer 配置与手动触发 // Logan.Li
import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listPeers, savePeer, deletePeer, handshakePeer, syncPeerNow, generateKey } from '@/api/sync'
const peers = ref([])
const nodeId = ref('')
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const form = reactive({ id: 0, name: '', base_url: '', shared_key: '' })
async function load() {
loading.value = true
try {
const res = await listPeers()
peers.value = res.data?.list || []
nodeId.value = res.data?.node_id || ''
} finally {
loading.value = false
}
}
function openCreate() {
Object.assign(form, { id: 0, name: '', base_url: '', shared_key: '' })
dialogVisible.value = true
}
async function genKey() {
const res = await generateKey()
form.shared_key = res.data?.shared_key || ''
}
async function submit() {
if (!form.name || !form.base_url || form.shared_key.length < 32) {
ElMessage.error('请完整填写名称、地址,密钥至少 32 位')
return
}
saving.value = true
try {
await savePeer({ ...form })
ElMessage.success('已保存')
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
async function doHandshake(row) {
const res = await handshakePeer(row.id)
if (res.code === 0) ElMessage.success(res.message)
await load()
}
async function doSync(row) {
const res = await syncPeerNow(row.id)
if (res.code === 0) ElMessage.success(res.message)
await load()
}
async function doDelete(row) {
await ElMessageBox.confirm(`确定删除互联节点「${row.name}」吗?删除后两台数据各自独立、不再同步。`, '删除确认', { type: 'warning' })
await deletePeer(row.id)
await load()
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.mb { margin-bottom: 16px; }
.node-info { font-size: 13px; color: var(--el-text-color-secondary); }
.node-info code { background: var(--el-fill-color); padding: 2px 6px; border-radius: 4px; }
.tip { font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.6; }
</style>