package main // cloudbind.go 实现“项目身份与机器路径分离”的同步支撑: // - machineID:本机稳定标识(哈希后缓存),云端行名 paths: // - 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) }