198 lines
6.1 KiB
Go
198 lines
6.1 KiB
Go
|
|
package main
|
|||
|
|
|
|||
|
|
// cloudbind.go 实现“项目身份与机器路径分离”的同步支撑:
|
|||
|
|
// - machineID:本机稳定标识(哈希后缓存),云端行名 paths:<machineID>
|
|||
|
|
// - machinePathsDoc / applyMachinePaths:只推拉本机的 {项目名:路径} 行
|
|||
|
|
// - 云端有身份但本机没路径的项目进入待绑定清单,由 Dashboard 引导用户选目录绑定。
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"crypto/sha256"
|
|||
|
|
"database/sql"
|
|||
|
|
"encoding/hex"
|
|||
|
|
"encoding/json"
|
|||
|
|
"errors"
|
|||
|
|
"os"
|
|||
|
|
"strings"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// machineID 返回本机稳定标识(原始 ID 哈希后取 16 个十六进制字符),缓存于 meta。
|
|||
|
|
func (a *App) machineID() string {
|
|||
|
|
if a.store == nil {
|
|||
|
|
return "unknown"
|
|||
|
|
}
|
|||
|
|
if v := a.store.Meta("machine_id"); v != "" {
|
|||
|
|
return v
|
|||
|
|
}
|
|||
|
|
raw := rawMachineID()
|
|||
|
|
if raw == "" {
|
|||
|
|
raw, _ = os.Hostname()
|
|||
|
|
}
|
|||
|
|
if raw == "" {
|
|||
|
|
raw = "unknown-machine"
|
|||
|
|
}
|
|||
|
|
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(raw))))
|
|||
|
|
id := hex.EncodeToString(sum[:8])
|
|||
|
|
_ = a.store.SetMeta("machine_id", id)
|
|||
|
|
return id
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// machinePathsDoc 生成本机路径文档 {项目名: 本机路径}(map 序列化按键排序,内容稳定可比较)。
|
|||
|
|
func (a *App) machinePathsDoc() (string, error) {
|
|||
|
|
rows, e := a.store.db.Query(`SELECT name,path FROM projects ORDER BY name`)
|
|||
|
|
if e != nil {
|
|||
|
|
return "", e
|
|||
|
|
}
|
|||
|
|
defer rows.Close()
|
|||
|
|
m := map[string]string{}
|
|||
|
|
for rows.Next() {
|
|||
|
|
var name, path string
|
|||
|
|
if e := rows.Scan(&name, &path); e != nil {
|
|||
|
|
return "", e
|
|||
|
|
}
|
|||
|
|
if name != "" && path != "" {
|
|||
|
|
m[name] = path
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
b, e := json.Marshal(m)
|
|||
|
|
return string(b), e
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// applyMachinePaths 套用本机路径文档(换库/重装同机场景):
|
|||
|
|
// 本地没有该名项目且路径非空 → 直接入库;已有同名项目但路径不同 → 以远端为准更新。
|
|||
|
|
func (a *App) applyMachinePaths(doc string) error {
|
|||
|
|
var m map[string]string
|
|||
|
|
if json.Unmarshal([]byte(doc), &m) != nil {
|
|||
|
|
return nil // 远端数据异常时忽略,不阻断同步
|
|||
|
|
}
|
|||
|
|
for name, path := range m {
|
|||
|
|
name, path = strings.TrimSpace(name), strings.TrimSpace(path)
|
|||
|
|
if name == "" || path == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
var pid int64
|
|||
|
|
var cur string
|
|||
|
|
e := a.store.db.QueryRow(`SELECT id,path FROM projects WHERE name=? LIMIT 1`, name).Scan(&pid, &cur)
|
|||
|
|
switch {
|
|||
|
|
case e == sql.ErrNoRows:
|
|||
|
|
now := nowRFC()
|
|||
|
|
// path 全局唯一:同路径已有别名项目时不强插(IGNORE)。
|
|||
|
|
if _, e := a.store.db.Exec(`INSERT OR IGNORE INTO projects(name,path,description,group_id,created_at,updated_at) VALUES(?,?,'',1,?,?)`,
|
|||
|
|
name, path, now, now); e != nil {
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
case e != nil:
|
|||
|
|
return e
|
|||
|
|
case cur != path:
|
|||
|
|
if _, e := a.store.db.Exec(`UPDATE projects SET path=?,updated_at=? WHERE id=?
|
|||
|
|
AND NOT EXISTS(SELECT 1 FROM projects p2 WHERE p2.path=? AND p2.id!=?)`,
|
|||
|
|
path, nowRFC(), pid, path, pid); e != nil {
|
|||
|
|
return e
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// rebuildCloudPending 以最近一次云端身份文档为基准,重算仍未落地本机的项目清单(幂等)。
|
|||
|
|
func (a *App) rebuildCloudPending(entries []projectDocEntry) {
|
|||
|
|
pending := []projectDocEntry{}
|
|||
|
|
for _, it := range entries {
|
|||
|
|
name := strings.TrimSpace(it.Name)
|
|||
|
|
if name == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
var pid int64
|
|||
|
|
if a.store.db.QueryRow(`SELECT id FROM projects WHERE name=? LIMIT 1`, name).Scan(&pid) == sql.ErrNoRows {
|
|||
|
|
it.Path = "" // 待绑定清单不携带其它机器的路径
|
|||
|
|
pending = append(pending, it)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
b, _ := json.Marshal(pending)
|
|||
|
|
_ = a.store.SetMeta("cloud_projects_pending", string(b))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ListCloudPendingProjects 返回云端存在但本机尚未绑定路径的项目。
|
|||
|
|
func (a *App) ListCloudPendingProjects() ([]projectDocEntry, error) {
|
|||
|
|
if e := a.ready(); e != nil {
|
|||
|
|
return nil, e
|
|||
|
|
}
|
|||
|
|
out := []projectDocEntry{}
|
|||
|
|
raw := a.store.Meta("cloud_projects_pending")
|
|||
|
|
if raw == "" {
|
|||
|
|
return out, nil
|
|||
|
|
}
|
|||
|
|
var list []projectDocEntry
|
|||
|
|
if json.Unmarshal([]byte(raw), &list) != nil {
|
|||
|
|
return out, nil
|
|||
|
|
}
|
|||
|
|
for _, it := range list {
|
|||
|
|
// 本地可能在同步间隙手动建了同名项目,再过滤一遍
|
|||
|
|
var pid int64
|
|||
|
|
if a.store.db.QueryRow(`SELECT id FROM projects WHERE name=? LIMIT 1`, it.Name).Scan(&pid) == sql.ErrNoRows {
|
|||
|
|
out = append(out, it)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return out, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// BindCloudProject 把云端项目绑定到本机目录:带身份信息入库、移出待绑定清单,并触发一轮推送。
|
|||
|
|
func (a *App) BindCloudProject(name, path string) (Project, error) {
|
|||
|
|
if e := a.ready(); e != nil {
|
|||
|
|
return Project{}, e
|
|||
|
|
}
|
|||
|
|
name, path = strings.TrimSpace(name), strings.TrimSpace(path)
|
|||
|
|
if name == "" {
|
|||
|
|
return Project{}, errors.New("NAME_REQUIRED")
|
|||
|
|
}
|
|||
|
|
if fi, e := os.Stat(path); e != nil || !fi.IsDir() {
|
|||
|
|
return Project{}, errors.New("DIR_NOT_FOUND")
|
|||
|
|
}
|
|||
|
|
entry := projectDocEntry{Name: name}
|
|||
|
|
if raw := a.store.Meta("cloud_projects_pending"); raw != "" {
|
|||
|
|
var list []projectDocEntry
|
|||
|
|
if json.Unmarshal([]byte(raw), &list) == nil {
|
|||
|
|
rest := make([]projectDocEntry, 0, len(list))
|
|||
|
|
for _, it := range list {
|
|||
|
|
if strings.TrimSpace(it.Name) == name {
|
|||
|
|
entry = it
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
rest = append(rest, it)
|
|||
|
|
}
|
|||
|
|
b, _ := json.Marshal(rest)
|
|||
|
|
_ = a.store.SetMeta("cloud_projects_pending", string(b))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
gid, e := a.ensureGroupID(entry.Group)
|
|||
|
|
if e != nil {
|
|||
|
|
return Project{}, e
|
|||
|
|
}
|
|||
|
|
var pid int64
|
|||
|
|
now := nowRFC()
|
|||
|
|
switch e := a.store.db.QueryRow(`SELECT id FROM projects WHERE path=?`, path).Scan(&pid); {
|
|||
|
|
case e == sql.ErrNoRows:
|
|||
|
|
r, e2 := a.store.db.Exec(`INSERT INTO projects(name,path,description,group_id,created_at,updated_at) VALUES(?,?,?,?,?,?)`,
|
|||
|
|
name, path, entry.Description, gid, now, now)
|
|||
|
|
if e2 != nil {
|
|||
|
|
return Project{}, e2
|
|||
|
|
}
|
|||
|
|
pid, _ = r.LastInsertId()
|
|||
|
|
case e != nil:
|
|||
|
|
return Project{}, e
|
|||
|
|
default:
|
|||
|
|
// 该目录已是本地项目:改名并入云端身份,避免出现两个同路径项目。
|
|||
|
|
if _, e := a.store.db.Exec(`UPDATE projects SET name=?,description=?,group_id=?,updated_at=? WHERE id=?`,
|
|||
|
|
name, entry.Description, gid, now, pid); e != nil {
|
|||
|
|
return Project{}, e
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if entry.Favorite {
|
|||
|
|
_, _ = a.store.db.Exec(`INSERT OR IGNORE INTO favorites(project_id,created_at) VALUES(?,?)`, pid, now)
|
|||
|
|
}
|
|||
|
|
a.store.Log("info", "同步", "云端项目已绑定本机目录", name+" → "+path)
|
|||
|
|
if a.syncUserID() > 0 {
|
|||
|
|
go a.syncOnce(true)
|
|||
|
|
}
|
|||
|
|
return a.store.GetProject(pid)
|
|||
|
|
}
|