feat: 节点互联双向同步——授权/计划/客户(邮箱)按业务键LWW增量同步,HTTPS+HMAC互联密钥,默认10分钟宽松节奏
This commit is contained in:
1 parent
06a9dac99b
commit
44387835e1
13 files changed
+1180
-1
No files matched your search
@@ -0,0 +1,173 @@
|
||||
//! 同步载荷应用:按业务键 upsert,仅当载荷新于本地时更新(last-write-wins)。 // Logan.Li
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
)
|
||||
|
||||
// upsertProduct 按产品 code 定位 // Logan.Li
|
||||
func (s *Service) upsertProduct(p dto.SyncProductPayload) bool {
|
||||
if strings.TrimSpace(p.Code) == "" {
|
||||
return false
|
||||
}
|
||||
var local model.Product
|
||||
err := s.db.Where("code = ?", p.Code).First(&local).Error
|
||||
if err != nil {
|
||||
local = model.Product{
|
||||
Code: p.Code, Name: p.Name, Version: p.Version, Description: p.Description,
|
||||
Features: p.Features, Price: p.Price, PriceModel: p.PriceModel,
|
||||
Status: p.Status, CreatedAt: p.UpdatedAt, UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
return s.db.Create(&local).Error == nil
|
||||
}
|
||||
if !p.UpdatedAt.After(local.UpdatedAt) {
|
||||
return false // 本地已是最新的,跳过 // Logan.Li
|
||||
}
|
||||
local.Name = p.Name
|
||||
local.Version = p.Version
|
||||
local.Description = p.Description
|
||||
local.Features = p.Features
|
||||
local.Price = p.Price
|
||||
local.PriceModel = p.PriceModel
|
||||
local.Status = p.Status
|
||||
return s.db.Save(&local).Error == nil
|
||||
}
|
||||
|
||||
// upsertPlan 按 产品code+plan_type+duration 定位 // Logan.Li
|
||||
func (s *Service) upsertPlan(pl dto.SyncPlanPayload) bool {
|
||||
if pl.ProductCode == "" {
|
||||
return false
|
||||
}
|
||||
var product model.Product
|
||||
if err := s.db.Where("code = ?", pl.ProductCode).First(&product).Error; err != nil {
|
||||
return false // 产品依赖未就绪,等下一轮 // Logan.Li
|
||||
}
|
||||
var local model.Plan
|
||||
err := s.db.Where("product_id = ? AND plan_type = ? AND duration = ?", product.ID, pl.PlanType, pl.Duration).First(&local).Error
|
||||
if err != nil {
|
||||
local = model.Plan{
|
||||
ProductID: product.ID, Name: pl.Name, PlanType: pl.PlanType,
|
||||
Price: pl.Price, Currency: pl.Currency, Duration: pl.Duration,
|
||||
MaxDevices: pl.MaxDevices, Features: pl.Features, SortOrder: pl.SortOrder,
|
||||
Status: pl.Status, CreatedAt: pl.UpdatedAt, UpdatedAt: pl.UpdatedAt,
|
||||
}
|
||||
return s.db.Create(&local).Error == nil
|
||||
}
|
||||
if !pl.UpdatedAt.After(local.UpdatedAt) {
|
||||
return false
|
||||
}
|
||||
local.Name = pl.Name
|
||||
local.Price = pl.Price
|
||||
local.Currency = pl.Currency
|
||||
local.MaxDevices = pl.MaxDevices
|
||||
local.Features = pl.Features
|
||||
local.SortOrder = pl.SortOrder
|
||||
local.Status = pl.Status
|
||||
return s.db.Save(&local).Error == nil
|
||||
}
|
||||
|
||||
// upsertCustomer 按 email 定位(空/占位邮箱回退 name),合规最小字段 // Logan.Li
|
||||
func (s *Service) upsertCustomer(c dto.SyncCustomerPayload) bool {
|
||||
email := strings.TrimSpace(c.Email)
|
||||
name := strings.TrimSpace(c.Name)
|
||||
var local model.Customer
|
||||
var err error
|
||||
if email != "" && !strings.HasSuffix(email, "@local.invalid") {
|
||||
err = s.db.Where("email = ?", email).First(&local).Error
|
||||
} else {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
err = s.db.Where("name = ?", name).Order("id ASC").First(&local).Error
|
||||
}
|
||||
if err != nil {
|
||||
if email == "" {
|
||||
email = fmt.Sprintf("sync+%d@local.invalid", time.Now().UnixNano())
|
||||
}
|
||||
local = model.Customer{
|
||||
Name: name, Company: c.Company, Email: email,
|
||||
Status: c.Status, CreatedAt: c.UpdatedAt, UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
return s.db.Create(&local).Error == nil
|
||||
}
|
||||
if !c.UpdatedAt.After(local.UpdatedAt) {
|
||||
return false
|
||||
}
|
||||
local.Name = name
|
||||
local.Company = c.Company
|
||||
if email != "" && !strings.HasSuffix(email, "@local.invalid") {
|
||||
local.Email = email
|
||||
}
|
||||
local.Status = c.Status
|
||||
return s.db.Save(&local).Error == nil
|
||||
}
|
||||
|
||||
// upsertLicense 按 license_key 定位,跨表解析产品与客户 // Logan.Li
|
||||
func (s *Service) upsertLicense(l dto.SyncLicensePayload) bool {
|
||||
if strings.TrimSpace(l.LicenseKey) == "" || l.ProductCode == "" {
|
||||
return false
|
||||
}
|
||||
var product model.Product
|
||||
if err := s.db.Where("code = ?", l.ProductCode).First(&product).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
customerID, err := s.resolveCustomer(l.CustomerEmail, l.CustomerName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var local model.License
|
||||
err = s.db.Where("license_key = ?", l.LicenseKey).First(&local).Error
|
||||
if err != nil {
|
||||
local = model.License{
|
||||
LicenseKey: l.LicenseKey, ProductID: product.ID, CustomerID: customerID,
|
||||
Status: l.Status, StartDate: l.StartDate, EndDate: l.EndDate,
|
||||
Duration: l.Duration, MaxActivations: l.MaxActivations,
|
||||
CurrentActivations: l.CurrentActivations,
|
||||
IsTrial: l.IsTrial, TrialDays: l.TrialDays,
|
||||
CreatedAt: l.UpdatedAt, UpdatedAt: l.UpdatedAt,
|
||||
}
|
||||
return s.db.Create(&local).Error == nil
|
||||
}
|
||||
if !l.UpdatedAt.After(local.UpdatedAt) {
|
||||
return false
|
||||
}
|
||||
local.ProductID = product.ID
|
||||
local.CustomerID = customerID
|
||||
local.Status = l.Status
|
||||
local.StartDate = l.StartDate
|
||||
local.EndDate = l.EndDate
|
||||
local.Duration = l.Duration
|
||||
local.MaxActivations = l.MaxActivations
|
||||
local.CurrentActivations = l.CurrentActivations
|
||||
local.IsTrial = l.IsTrial
|
||||
local.TrialDays = l.TrialDays
|
||||
return s.db.Save(&local).Error == nil
|
||||
}
|
||||
|
||||
// resolveCustomer 按邮箱解析或创建客户(离线占位客户按名字复用) // Logan.Li
|
||||
func (s *Service) resolveCustomer(email, name string) (uint, error) {
|
||||
email = strings.TrimSpace(email)
|
||||
name = strings.TrimSpace(name)
|
||||
if email == "" {
|
||||
email = fmt.Sprintf("sync+%d@local.invalid", time.Now().UnixNano())
|
||||
}
|
||||
var customer model.Customer
|
||||
if err := s.db.Where("email = ?", email).First(&customer).Error; err == nil {
|
||||
return customer.ID, nil
|
||||
}
|
||||
customer = model.Customer{Name: name, Email: email, Status: 1}
|
||||
if err := s.db.Create(&customer).Error; err != nil {
|
||||
// 并发下可能已存在,再查一次 // Logan.Li
|
||||
if err2 := s.db.Where("email = ?", email).First(&customer).Error; err2 == nil {
|
||||
return customer.ID, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return customer.ID, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! 同步交换:增量收集、按业务键 LWW 应用(产品→计划→客户→授权)、双向循环。 // Logan.Li
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
)
|
||||
|
||||
// ResolvePeer 按签名定位对端:优先 remote_node 已回填记录,否则逐个验签(首次互联场景) // Logan.Li
|
||||
func (s *Service) ResolvePeer(remoteNodeID string, verify func(*model.PeerNode) error) (*model.PeerNode, error) {
|
||||
var peers []model.PeerNode
|
||||
s.db.Where("remote_node = ?", remoteNodeID).Find(&peers)
|
||||
if len(peers) == 0 {
|
||||
s.db.Where("status != ?", model.PeerStatusDisabled).Find(&peers)
|
||||
}
|
||||
for i := range peers {
|
||||
if err := verify(&peers[i]); err == nil {
|
||||
// 首次验签成功即回填对端节点 ID // Logan.Li
|
||||
if peers[i].RemoteNode == "" {
|
||||
s.db.Model(&peers[i]).Update("remote_node", remoteNodeID)
|
||||
}
|
||||
return &peers[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("互联密钥校验失败(对端 node=%s)", remoteNodeID)
|
||||
}
|
||||
|
||||
// ============ 出站交换(主动方) ============
|
||||
|
||||
// ExchangeOut 与对端做一轮双向增量交换(手动按钮与定时循环共用) // Logan.Li
|
||||
func (s *Service) ExchangeOut(peer *model.PeerNode) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cursor := time.Time{}
|
||||
if peer.LastSyncAt != nil {
|
||||
cursor = *peer.LastSyncAt
|
||||
}
|
||||
batchStart := time.Now()
|
||||
items := s.gatherSince(cursor)
|
||||
reqBody, _ := json.Marshal(dto.SyncExchangeRequest{Cursor: cursor, Items: items})
|
||||
|
||||
var resp dto.SyncExchangeResponse
|
||||
if err := s.signedPost(peer, exchangePath, reqBody, &resp); err != nil {
|
||||
result := "同步失败: " + truncate(err.Error(), 180)
|
||||
s.db.Model(peer).Update("last_result", result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
applied := s.applyItems(resp.Items)
|
||||
newCursor := batchStart
|
||||
s.db.Model(peer).Updates(map[string]interface{}{
|
||||
"last_sync_at": &newCursor,
|
||||
"last_result": fmt.Sprintf("双向同步成功 %s(对端时间 %s)",
|
||||
formatApplied(applied), resp.ServerTime.Format("01-02 15:04")),
|
||||
})
|
||||
return formatApplied(applied), nil
|
||||
}
|
||||
|
||||
// gatherSince 收集 updated_at 晚于游标的增量(依赖序:产品→计划→客户→授权) // Logan.Li
|
||||
func (s *Service) gatherSince(cursor time.Time) dto.SyncItems {
|
||||
var products []model.Product
|
||||
s.db.Where("updated_at > ?", cursor).Order("updated_at ASC").Limit(syncBatchSize).Find(&products)
|
||||
var plans []model.Plan
|
||||
s.db.Where("updated_at > ?", cursor).Order("updated_at ASC").Limit(syncBatchSize).Find(&plans)
|
||||
var customers []model.Customer
|
||||
s.db.Where("updated_at > ?", cursor).Order("updated_at ASC").Limit(syncBatchSize).Find(&customers)
|
||||
var licenses []model.License
|
||||
s.db.Where("updated_at > ?", cursor).Order("updated_at ASC").Limit(syncBatchSize).Find(&licenses)
|
||||
|
||||
items := dto.SyncItems{Products: []dto.SyncProductPayload{}, Plans: []dto.SyncPlanPayload{},
|
||||
Customers: []dto.SyncCustomerPayload{}, Licenses: []dto.SyncLicensePayload{}}
|
||||
for _, p := range products {
|
||||
items.Products = append(items.Products, dto.SyncProductPayload{
|
||||
Code: p.Code, Name: p.Name, Version: p.Version, Description: p.Description,
|
||||
Features: p.Features, Price: p.Price, PriceModel: p.PriceModel,
|
||||
Status: p.Status, UpdatedAt: p.UpdatedAt,
|
||||
})
|
||||
}
|
||||
productCodeByID := map[uint]string{}
|
||||
for _, p := range products {
|
||||
productCodeByID[p.ID] = p.Code
|
||||
}
|
||||
// 计划的产品可能不在本批增量里,补查全量映射 // Logan.Li
|
||||
var allProducts []model.Product
|
||||
s.db.Select("id, code").Find(&allProducts)
|
||||
for _, p := range allProducts {
|
||||
productCodeByID[p.ID] = p.Code
|
||||
}
|
||||
for _, pl := range plans {
|
||||
items.Plans = append(items.Plans, dto.SyncPlanPayload{
|
||||
ProductCode: productCodeByID[pl.ProductID], Name: pl.Name, PlanType: pl.PlanType,
|
||||
Price: pl.Price, Currency: pl.Currency, Duration: pl.Duration,
|
||||
MaxDevices: pl.MaxDevices, Features: pl.Features, SortOrder: pl.SortOrder,
|
||||
Status: pl.Status, UpdatedAt: pl.UpdatedAt,
|
||||
})
|
||||
}
|
||||
// 授权关联的客户信息补查(客户可能不在本批增量里) // Logan.Li
|
||||
custByID := map[uint]model.Customer{}
|
||||
var related []model.Customer
|
||||
s.db.Find(&related)
|
||||
for _, c := range related {
|
||||
custByID[c.ID] = c
|
||||
}
|
||||
for _, l := range licenses {
|
||||
c := custByID[l.CustomerID]
|
||||
items.Licenses = append(items.Licenses, dto.SyncLicensePayload{
|
||||
LicenseKey: l.LicenseKey, ProductCode: productCodeByID[l.ProductID],
|
||||
CustomerEmail: c.Email, CustomerName: c.Name,
|
||||
Status: l.Status, StartDate: l.StartDate, EndDate: l.EndDate,
|
||||
Duration: l.Duration, MaxActivations: l.MaxActivations,
|
||||
CurrentActivations: l.CurrentActivations,
|
||||
IsTrial: l.IsTrial, TrialDays: l.TrialDays, UpdatedAt: l.UpdatedAt,
|
||||
})
|
||||
}
|
||||
for _, c := range customers {
|
||||
items.Customers = append(items.Customers, dto.SyncCustomerPayload{
|
||||
Name: c.Name, Company: c.Company, Email: c.Email, Status: c.Status, UpdatedAt: c.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// ============ 入站应用(响应方) ============
|
||||
|
||||
// ExchangeIn 处理对端推送:应用其增量,返回我方增量 // Logan.Li
|
||||
func (s *Service) ExchangeIn(peer *model.PeerNode, req dto.SyncExchangeRequest) (*dto.SyncExchangeResponse, error) {
|
||||
applied := s.applyItems(req.Items)
|
||||
|
||||
gatherStart := time.Now()
|
||||
items := s.gatherSince(peerOutCursor(peer))
|
||||
s.db.Model(peer).Updates(map[string]interface{}{
|
||||
"last_sync_at": &gatherStart,
|
||||
"last_result": "入站同步成功 " + formatApplied(applied),
|
||||
})
|
||||
return &dto.SyncExchangeResponse{
|
||||
Items: items, Applied: applied, ServerTime: time.Now().UTC(),
|
||||
NodeInfo: map[string]string{"node_id": s.nodeID, "name": s.hostName()},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// peerOutCursor 出站游标即该 peer 记录的 last_sync_at(双向各自维护) // Logan.Li
|
||||
func peerOutCursor(peer *model.PeerNode) time.Time {
|
||||
if peer.LastSyncAt != nil {
|
||||
return *peer.LastSyncAt
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// applyItems 按依赖顺序 LWW 应用(业务键定位,更新只在新于本地时生效) // Logan.Li
|
||||
func (s *Service) applyItems(items dto.SyncItems) map[string]int {
|
||||
applied := map[string]int{"products": 0, "plans": 0, "customers": 0, "licenses": 0, "skipped": 0}
|
||||
for _, p := range items.Products {
|
||||
if s.upsertProduct(p) {
|
||||
applied["products"]++
|
||||
} else {
|
||||
applied["skipped"]++
|
||||
}
|
||||
}
|
||||
for _, pl := range items.Plans {
|
||||
if s.upsertPlan(pl) {
|
||||
applied["plans"]++
|
||||
} else {
|
||||
applied["skipped"]++
|
||||
}
|
||||
}
|
||||
for _, c := range items.Customers {
|
||||
if s.upsertCustomer(c) {
|
||||
applied["customers"]++
|
||||
} else {
|
||||
applied["skipped"]++
|
||||
}
|
||||
}
|
||||
for _, l := range items.Licenses {
|
||||
if s.upsertLicense(l) {
|
||||
applied["licenses"]++
|
||||
} else {
|
||||
applied["skipped"]++
|
||||
}
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
func formatApplied(m map[string]int) string {
|
||||
return fmt.Sprintf("产品%d/计划%d/客户%d/授权%d(跳过%d)",
|
||||
m["products"], m["plans"], m["customers"], m["licenses"], m["skipped"])
|
||||
}
|
||||
|
||||
// ============ 定时循环 ============
|
||||
|
||||
// StartSyncLoop 启动宽松节奏的定时双向同步(默认 10 分钟一轮,失败顺延下轮重试) // Logan.Li
|
||||
func (s *Service) StartSyncLoop(intervalMinutes int) {
|
||||
if intervalMinutes <= 0 {
|
||||
intervalMinutes = 10
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
var peers []model.PeerNode
|
||||
s.db.Where("status = ?", model.PeerStatusActive).Find(&peers)
|
||||
for _, peer := range peers {
|
||||
p := peer
|
||||
if _, err := s.ExchangeOut(&p); err != nil {
|
||||
log.Printf("[同步] 节点 %s 本轮失败: %v", peer.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
log.Printf("[同步] 定时双向同步已启动(每 %d 分钟)", intervalMinutes)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! 节点互联服务:双向授权同步的 peer 管理、HMAC 认证、握手与增量交换。
|
||||
//! 加密通道 = 站点既有 HTTPS + 互联密钥 HMAC-SHA256 签名(防篡改/防重放),无需额外隧道。 // Logan.Li
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"backend/config"
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
syncBatchSize = 500 // 单实体单轮增量上限,宽松分批 // Logan.Li
|
||||
handshakePath = "/v1/sync/handshake"
|
||||
exchangePath = "/v1/sync/exchange"
|
||||
sigTimeWindow = 5 * time.Minute // 签名时间戳容差(防重放) // Logan.Li
|
||||
httpTimeout = 20 * time.Second
|
||||
protocolVersion = "1"
|
||||
)
|
||||
|
||||
// Service 节点互联业务逻辑层 // Logan.Li
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
nodeID string
|
||||
client *http.Client
|
||||
mu sync.Mutex // 同一时刻仅一轮交换,防游标错乱 // Logan.Li
|
||||
}
|
||||
|
||||
// NewService 创建 service 实例;节点 ID 由 JWT_SECRET 派生(两节点密钥不同,天然互异且稳定) // Logan.Li
|
||||
func NewService(db *gorm.DB, cfg *config.Config) *Service {
|
||||
sum := sha256.Sum256([]byte("opsx-sync-node:" + cfg.JWTSecret))
|
||||
return &Service{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
nodeID: hex.EncodeToString(sum[:8]),
|
||||
client: &http.Client{Timeout: httpTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
// NodeID 本节点标识 // Logan.Li
|
||||
func (s *Service) NodeID() string { return s.nodeID }
|
||||
|
||||
// GenerateSharedKey 生成 32 字节 hex 互联密钥 // Logan.Li
|
||||
func GenerateSharedKey() string {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
panic("生成互联密钥失败: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// ============ Peer 管理 ============
|
||||
|
||||
// ListPeers 节点列表 // Logan.Li
|
||||
func (s *Service) ListPeers() ([]dto.PeerResponse, error) {
|
||||
var peers []model.PeerNode
|
||||
if err := s.db.Order("id ASC").Find(&peers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]dto.PeerResponse, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
results = append(results, toPeerResponse(p))
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SavePeer 创建或更新节点(ID=0 创建) // Logan.Li
|
||||
func (s *Service) SavePeer(req dto.PeerRequest) (*dto.PeerResponse, error) {
|
||||
base := normalizeBaseURL(req.BaseURL)
|
||||
key := strings.TrimSpace(req.SharedKey)
|
||||
if len(key) < 32 {
|
||||
return nil, errors.New("互联密钥至少 32 位(建议用生成按钮)")
|
||||
}
|
||||
var peer model.PeerNode
|
||||
if req.ID > 0 {
|
||||
if err := s.db.First(&peer, req.ID).Error; err != nil {
|
||||
return nil, errors.New("节点不存在")
|
||||
}
|
||||
peer.Name = req.Name
|
||||
peer.BaseURL = base
|
||||
peer.SharedKey = key
|
||||
peer.Status = model.PeerStatusPending // 配置变更后需重新握手确认 // Logan.Li
|
||||
} else {
|
||||
var count int64
|
||||
s.db.Model(&model.PeerNode{}).Where("base_url = ?", base).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, errors.New("该地址的节点已存在")
|
||||
}
|
||||
peer = model.PeerNode{Name: req.Name, BaseURL: base, SharedKey: key, Status: model.PeerStatusPending}
|
||||
}
|
||||
if err := s.db.Save(&peer).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := toPeerResponse(peer)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeletePeer 删除节点 // Logan.Li
|
||||
func (s *Service) DeletePeer(id uint) error {
|
||||
return s.db.Delete(&model.PeerNode{}, id).Error
|
||||
}
|
||||
|
||||
func toPeerResponse(p model.PeerNode) dto.PeerResponse {
|
||||
tail := ""
|
||||
if len(p.SharedKey) >= 4 {
|
||||
tail = p.SharedKey[len(p.SharedKey)-4:]
|
||||
}
|
||||
return dto.PeerResponse{
|
||||
ID: p.ID, Name: p.Name, BaseURL: p.BaseURL, Status: p.Status,
|
||||
RemoteNode: p.RemoteNode, KeyTail: tail,
|
||||
LastSyncAt: p.LastSyncAt, LastResult: p.LastResult, CreatedAt: p.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeBaseURL 统一为 https://host/api 形式;本地回环地址保留 http 供联调 // Logan.Li
|
||||
func normalizeBaseURL(raw string) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if base == "" {
|
||||
return base
|
||||
}
|
||||
isLocal := strings.Contains(base, "//127.0.0.1") || strings.Contains(base, "//localhost")
|
||||
if strings.HasPrefix(base, "http://") && !isLocal {
|
||||
base = "https://" + strings.TrimPrefix(base, "http://")
|
||||
}
|
||||
if strings.HasSuffix(base, "/api/v1") {
|
||||
base = strings.TrimSuffix(base, "/v1")
|
||||
} else if !strings.HasSuffix(base, "/api") {
|
||||
base += "/api"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// ============ HMAC 签名 ============
|
||||
|
||||
// buildSignature 计算请求签名:HMAC-SHA256(key, "v1|node|ts|bodySha256") // Logan.Li
|
||||
func buildSignature(key, nodeID string, ts int64, body []byte) string {
|
||||
bodySum := sha256.Sum256(body)
|
||||
mac := hmac.New(sha256.New, []byte(key))
|
||||
fmt.Fprintf(mac, "v1|%s|%d|%s", nodeID, ts, hex.EncodeToString(bodySum[:]))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// VerifySignature 校验入站请求签名与时间窗 // Logan.Li
|
||||
func VerifySignature(key string, r *http.Request, body []byte) error {
|
||||
nodeID := r.Header.Get("X-Sync-Node")
|
||||
tsStr := r.Header.Get("X-Sync-Ts")
|
||||
sig := r.Header.Get("X-Sync-Sig")
|
||||
if nodeID == "" || tsStr == "" || sig == "" {
|
||||
return errors.New("缺少同步签名头")
|
||||
}
|
||||
var ts int64
|
||||
if _, err := fmt.Sscanf(tsStr, "%d", &ts); err != nil {
|
||||
return errors.New("签名时间戳非法")
|
||||
}
|
||||
if absDuration(time.Since(time.Unix(ts, 0))) > sigTimeWindow {
|
||||
return errors.New("签名已过期(时钟偏差过大)")
|
||||
}
|
||||
expect := buildSignature(key, nodeID, ts, body)
|
||||
if !hmac.Equal([]byte(expect), []byte(sig)) {
|
||||
return errors.New("互联密钥校验失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func absDuration(d time.Duration) time.Duration {
|
||||
if d < 0 {
|
||||
return -d
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// ============ 握手 ============
|
||||
|
||||
// handshakeOut 对端发起握手 // Logan.Li
|
||||
func (s *Service) handshakeOut(peer *model.PeerNode) (*dto.SyncHandshakeResponse, error) {
|
||||
body, _ := json.Marshal(dto.SyncHandshakeRequest{NodeName: s.hostName()})
|
||||
var resp dto.SyncHandshakeResponse
|
||||
if err := s.signedPost(peer, handshakePath, body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// HandshakeIn 入站握手:签名已由 resolvePeer 按互联密钥验证,此处互认并激活 // Logan.Li
|
||||
func (s *Service) HandshakeIn(peer *model.PeerNode, req dto.SyncHandshakeRequest) (*dto.SyncHandshakeResponse, error) {
|
||||
if peer.Status == model.PeerStatusDisabled {
|
||||
return nil, errors.New("本侧已停用该互联")
|
||||
}
|
||||
if peer.Status != model.PeerStatusActive {
|
||||
s.db.Model(peer).Updates(map[string]interface{}{"status": model.PeerStatusActive})
|
||||
}
|
||||
return &dto.SyncHandshakeResponse{
|
||||
NodeID: s.nodeID, NodeName: s.hostName(), Version: protocolVersion, ServerTime: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestHandshake 管理端手动握手:成功则双方激活 // Logan.Li
|
||||
func (s *Service) TestHandshake(id uint) (*dto.PeerResponse, error) {
|
||||
var peer model.PeerNode
|
||||
if err := s.db.First(&peer, id).Error; err != nil {
|
||||
return nil, errors.New("节点不存在")
|
||||
}
|
||||
resp, err := s.handshakeOut(&peer)
|
||||
if err != nil {
|
||||
s.db.Model(&peer).Updates(map[string]interface{}{"last_result": "握手失败: " + truncate(err.Error(), 200)})
|
||||
return nil, fmt.Errorf("握手失败: %s", err.Error())
|
||||
}
|
||||
s.db.Model(&peer).Updates(map[string]interface{}{
|
||||
"status": model.PeerStatusActive, "remote_node": resp.NodeID,
|
||||
"last_result": "握手成功,对端: " + resp.NodeName,
|
||||
})
|
||||
_ = s.db.First(&peer, id).Error
|
||||
out := toPeerResponse(peer)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// signedPost 携带 HMAC 签名调用对端 // Logan.Li
|
||||
func (s *Service) signedPost(peer *model.PeerNode, path string, body []byte, out interface{}) error {
|
||||
url := strings.TrimRight(peer.BaseURL, "/") + path
|
||||
ts := time.Now().Unix()
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Sync-Node", s.nodeID)
|
||||
req.Header.Set("X-Sync-Ts", fmt.Sprintf("%d", ts))
|
||||
req.Header.Set("X-Sync-Sig", buildSignature(peer.SharedKey, s.nodeID, ts, body))
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求对端失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("对端返回 %d: %s", resp.StatusCode, truncate(string(raw), 120))
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("对端响应解析失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hostName 对端展示用本机名(无 os/env 依赖,用节点 ID 前缀) // Logan.Li
|
||||
func (s *Service) hostName() string {
|
||||
return "node-" + s.nodeID[:8]
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
Reference in new issue
Block a user