Files
code-utils/sync.go
2026-08-15 07:29:45 +08:00

1315 lines
44 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// sync.go 实现登录 + MySQL 同步引擎(本地优先):
// 推送本地 dirty 行 → 按 updated_at 增量拉取远端 → Last-Write-Wins 冲突合并。
// 触发时机:登录成功、每 5 分钟巡检(自带断网恢复探测)、设置页手动同步。
import (
"context"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"golang.org/x/crypto/bcrypt"
)
const syncTimeout = 10 * time.Second
// ---------- 配置与状态 ----------
// syncConfig 解析同步服务器连接。
// 启动时会把打包配置写入本地 SQLite meta见 applyPackagedSyncConfig
// 运行期以 meta 为准meta 为空时回退到打包嵌入配置。
func (a *App) syncConfig() SyncConfig {
port, _ := strconv.Atoi(a.store.Meta("sync_port"))
c := SyncConfig{
Host: strings.TrimSpace(a.store.Meta("sync_host")),
Port: port,
User: strings.TrimSpace(a.store.Meta("sync_user")),
Password: a.store.Meta("sync_password"),
Database: strings.TrimSpace(a.store.Meta("sync_database")),
}
if syncConfigComplete(c) {
if c.Port <= 0 || c.Port > 65535 {
c.Port = 3306
}
return c
}
pkg := packagedSyncDefaults()
if syncConfigComplete(pkg) {
return pkg
}
return SyncConfig{Host: "127.0.0.1", Port: 3306, User: "root", Password: "root", Database: "code_count"}
}
// applyPackagedSyncConfig 启动时用打包配置覆盖本地 SQLite 中的同步连接,
// 避免旧安装残留的 127.0.0.1 等 meta 继续生效。
func (a *App) applyPackagedSyncConfig() {
if a.store == nil {
return
}
pkg := packagedSyncDefaults()
if !syncConfigComplete(pkg) {
return
}
_ = a.store.SetMeta("sync_host", strings.TrimSpace(pkg.Host))
_ = a.store.SetMeta("sync_port", strconv.Itoa(pkg.Port))
_ = a.store.SetMeta("sync_user", strings.TrimSpace(pkg.User))
_ = a.store.SetMeta("sync_password", pkg.Password)
_ = a.store.SetMeta("sync_database", strings.TrimSpace(pkg.Database))
}
func syncConfigComplete(c SyncConfig) bool {
return c.Host != "" && c.User != "" && c.Database != ""
}
func syncDSN(c SyncConfig) string {
cfg := mysql.NewConfig()
cfg.User = c.User
cfg.Passwd = c.Password
cfg.Net = "tcp"
cfg.Addr = fmt.Sprintf("%s:%d", c.Host, c.Port)
cfg.DBName = c.Database
cfg.Timeout = 5 * time.Second
cfg.ReadTimeout = syncTimeout
cfg.WriteTimeout = syncTimeout
cfg.Params = map[string]string{"charset": "utf8mb4"}
return cfg.FormatDSN()
}
func (a *App) GetSyncConfig() (SyncConfig, error) {
if e := a.ready(); e != nil {
return SyncConfig{}, e
}
return a.syncConfig(), nil
}
func (a *App) SaveSyncConfig(c SyncConfig) error {
if e := a.ready(); e != nil {
return e
}
if c.Port <= 0 || c.Port > 65535 {
c.Port = 3306
}
for k, v := range map[string]string{
"sync_host": strings.TrimSpace(c.Host), "sync_port": strconv.Itoa(c.Port),
"sync_user": strings.TrimSpace(c.User), "sync_password": c.Password,
"sync_database": strings.TrimSpace(c.Database),
} {
if e := a.store.SetMeta(k, v); e != nil {
return e
}
}
return nil
}
func (a *App) syncUserID() int64 {
id, _ := strconv.ParseInt(a.store.Meta("sync_user_id"), 10, 64)
return id
}
func (a *App) GetSyncStatus() (SyncStatus, error) {
if e := a.ready(); e != nil {
return SyncStatus{}, e
}
a.syncMu.Lock()
lastErr := a.syncLastErr
a.syncMu.Unlock()
var pending int
_ = a.store.db.QueryRow(`SELECT (SELECT COUNT(*) FROM todos WHERE dirty=1)
+(SELECT COUNT(*) FROM tickets WHERE dirty=1)
+(SELECT COUNT(*) FROM notes WHERE dirty=1)`).Scan(&pending)
return SyncStatus{
Configured: syncConfigComplete(a.syncConfig()),
LoggedIn: a.syncUserID() > 0,
UserID: a.syncUserID(),
Username: a.store.Meta("sync_username"),
LastSyncAt: a.store.Meta("sync_last_at"),
Syncing: a.syncing.Load(),
Online: a.syncOnline.Load(),
LastError: lastErr,
Pending: pending,
}, nil
}
// ---------- 远端连接 ----------
// openRemote 建立 MySQL 连接。应用与前端不执行任何建表/迁移DDL
// 数据库与数据表须提前用仓库根目录的 init.sql 初始化。
func (a *App) openRemote(ctx context.Context) (*sql.DB, error) {
c := a.syncConfig()
if !syncConfigComplete(c) {
return nil, errors.New("SYNC_NOT_CONFIGURED")
}
db, e := sql.Open("mysql", syncDSN(c))
if e != nil {
return nil, errors.New("SYNC_OFFLINE")
}
db.SetMaxOpenConns(2)
db.SetConnMaxLifetime(time.Minute)
if e = db.PingContext(ctx); e != nil {
db.Close()
if isSchemaMissing(e) {
return nil, errors.New("SYNC_SCHEMA_MISSING")
}
return nil, errors.New("SYNC_OFFLINE")
}
return db, nil
}
// isSchemaMissing 识别"库/表不存在"类错误1049 未知数据库、1146 表不存在)。
func isSchemaMissing(e error) bool {
var me *mysql.MySQLError
return errors.As(e, &me) && (me.Number == 1049 || me.Number == 1146)
}
// mapSyncErr 将底层 MySQL 错误翻译为稳定错误码,未识别的原样返回。
func mapSyncErr(e error) error {
if e == nil {
return nil
}
if isSchemaMissing(e) {
return errors.New("SYNC_SCHEMA_MISSING")
}
// 1054 未知列:远端表结构落后(如缺 history 列),提示重跑 init.sql。
var me *mysql.MySQLError
if errors.As(e, &me) && me.Number == 1054 {
return errors.New("SYNC_SCHEMA_OUTDATED")
}
return e
}
// ---------- 注册 / 登录 / 登出 ----------
func validAccount(username, password string) error {
username = strings.TrimSpace(username)
if len(username) < 3 || len(username) > 64 {
return errors.New("SYNC_USERNAME_INVALID")
}
if len(password) < 6 {
return errors.New("SYNC_PASSWORD_TOO_SHORT")
}
return nil
}
func (a *App) SyncRegister(username, password string) error {
if e := a.ready(); e != nil {
return e
}
if e := validAccount(username, password); e != nil {
return e
}
ctx, cancel := context.WithTimeout(a.ctx, syncTimeout)
defer cancel()
db, e := a.openRemote(ctx)
if e != nil {
return e
}
defer db.Close()
// 先查重给出明确报错;并发注册的极端情况由 username 唯一索引兜底(下方 1062
var exists int
if e := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE username=?`,
strings.TrimSpace(username)).Scan(&exists); e == nil && exists > 0 {
return errors.New("SYNC_USER_EXISTS")
}
hash, e := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if e != nil {
return e
}
_, e = db.ExecContext(ctx, `INSERT INTO users(username,password_hash,created_at) VALUES(?,?,?)`,
strings.TrimSpace(username), string(hash), nowRFC())
var me *mysql.MySQLError
if errors.As(e, &me) && me.Number == 1062 {
return errors.New("SYNC_USER_EXISTS")
}
if e == nil {
// 新账号默认用应用 logo 当头像base64注册后的自动登录同步会推成账号头像。
a.ensureDefaultAvatar()
}
return mapSyncErr(e)
}
func (a *App) SyncLogin(username, password string) (SyncStatus, error) {
if e := a.ready(); e != nil {
return SyncStatus{}, e
}
username = strings.TrimSpace(username)
ctx, cancel := context.WithTimeout(a.ctx, syncTimeout)
defer cancel()
db, e := a.openRemote(ctx)
if e != nil {
return SyncStatus{}, e
}
defer db.Close()
var id int64
var hash string
e = db.QueryRowContext(ctx, `SELECT id,password_hash FROM users WHERE username=?`, username).Scan(&id, &hash)
if e == sql.ErrNoRows || (e == nil && bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil) {
return SyncStatus{}, errors.New("SYNC_BAD_CREDENTIALS")
}
if e != nil {
return SyncStatus{}, mapSyncErr(e)
}
// 切换账号时重置拉取游标,并把本地数据整体标脏,合并进新账号。
if prev := a.store.Meta("sync_last_user_id"); prev != "" && prev != strconv.FormatInt(id, 10) {
for _, t := range []string{"todos", "tickets", "notes"} {
_ = a.store.SetMeta("sync_pull_"+t, "")
_, _ = a.store.db.Exec(`UPDATE ` + t + ` SET dirty=1`)
}
}
_ = a.store.SetMeta("sync_user_id", strconv.FormatInt(id, 10))
_ = a.store.SetMeta("sync_last_user_id", strconv.FormatInt(id, 10))
_ = a.store.SetMeta("sync_username", username)
// 派生 API Key 加密密钥(可选功能,失败不阻断登录,只记录日志)。
if ee := a.ensureEncKey(ctx, db, id, password); ee != nil {
_ = a.store.SetMeta("sync_enc_key", "")
a.store.Log("warning", "同步", "API Key 加密密钥派生失败", ee.Error())
}
a.store.Log("info", "同步", "登录成功", username)
a.RefreshShell() // 原生账号菜单切换为已登录形态
go a.syncOnce(true)
return a.GetSyncStatus()
}
// SyncChangePassword 在线修改登录密码。加密密钥由密码派生,因此会同步轮换:
// 云端 API Key 密文先用旧密钥解密(失败则回退本地明文重建,再不行就删除该行),
// 再用新密钥重加密,与 password_hash 更新放在同一事务里提交。
// 其他已登录设备的旧密钥会失效,需用新密码重新登录。
func (a *App) SyncChangePassword(oldPassword, newPassword string) error {
if e := a.ready(); e != nil {
return e
}
userID := a.syncUserID()
if userID <= 0 {
return errors.New("SYNC_NOT_LOGGED_IN")
}
if len(newPassword) < 6 {
return errors.New("SYNC_PASSWORD_TOO_SHORT")
}
ctx, cancel := context.WithTimeout(a.ctx, syncTimeout)
defer cancel()
db, e := a.openRemote(ctx)
if e != nil {
return e
}
defer db.Close()
var hash string
if e = db.QueryRowContext(ctx, `SELECT password_hash FROM users WHERE id=?`, userID).Scan(&hash); e != nil {
return mapSyncErr(e)
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(oldPassword)) != nil {
return errors.New("SYNC_OLD_PASSWORD_WRONG")
}
newHash, e := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if e != nil {
return e
}
// 盐保持不变,新密码 + 原盐派生新密钥;缺盐(从未用过加密同步)时跳过密钥轮换。
var newKey []byte
var salt string
se := db.QueryRowContext(ctx, `SELECT value FROM sync_settings WHERE user_id=? AND name='enc_salt'`, userID).Scan(&salt)
if se == nil {
if raw, de := hex.DecodeString(strings.TrimSpace(salt)); de == nil && len(raw) >= 8 {
newKey = deriveEncKey(newPassword, raw)
}
} else if se != sql.ErrNoRows && !isSchemaMissing(se) {
return mapSyncErr(se)
}
tx, e := db.BeginTx(ctx, nil)
if e != nil {
return mapSyncErr(e)
}
defer tx.Rollback()
if _, e = tx.ExecContext(ctx, `UPDATE users SET password_hash=? WHERE id=?`, string(newHash), userID); e != nil {
return mapSyncErr(e)
}
if newKey != nil {
var blob string
be := tx.QueryRowContext(ctx, `SELECT value FROM sync_settings WHERE user_id=? AND name='api_keys'`, userID).Scan(&blob)
if be != nil && be != sql.ErrNoRows {
return mapSyncErr(be)
}
if be == nil {
plain, de := "", errors.New("SYNC_DECRYPT_FAILED")
fromRemote := false
if old := a.encKey(); old != nil {
plain, de = decryptWithKey(old, blob)
fromRemote = de == nil
}
if de != nil {
// 旧密钥不可用时用本地明文 Key 重建密文;本地也没有就删除云端行。
if st, le := a.store.Settings(); le == nil && (st.SparkKey != "" || st.DeepSeekKey != "") {
b, _ := json.Marshal(map[string]string{"sparkKey": st.SparkKey, "deepSeekKey": st.DeepSeekKey})
plain, de = string(b), nil
}
}
switch {
case de == nil && fromRemote:
// 内容未变只换密文保留原时间戳LWW 语义完全不受影响。
enc, ee := encryptWithKey(newKey, plain)
if ee != nil {
return ee
}
if _, e = tx.ExecContext(ctx, `UPDATE sync_settings SET value=? WHERE user_id=? AND name='api_keys'`, enc, userID); e != nil {
return mapSyncErr(e)
}
case de == nil:
// 用本地内容重建:算一次内容更新,打新时间戳。
enc, ee := encryptWithKey(newKey, plain)
if ee != nil {
return ee
}
if _, e = tx.ExecContext(ctx, `UPDATE sync_settings SET value=?,updated_at=? WHERE user_id=? AND name='api_keys'`, enc, nowRFC(), userID); e != nil {
return mapSyncErr(e)
}
default:
if _, e = tx.ExecContext(ctx, `DELETE FROM sync_settings WHERE user_id=? AND name='api_keys'`, userID); e != nil {
return mapSyncErr(e)
}
}
}
}
if e = tx.Commit(); e != nil {
return mapSyncErr(e)
}
if newKey != nil {
_ = a.store.SetMeta("sync_enc_key", hex.EncodeToString(newKey))
} else {
_ = a.store.SetMeta("sync_enc_key", "")
}
a.store.Log("info", "同步", "密码修改成功", a.store.Meta("sync_username"))
return nil
}
func (a *App) SyncLogout() error {
if e := a.ready(); e != nil {
return e
}
_ = a.store.SetMeta("sync_user_id", "")
_ = a.store.SetMeta("sync_username", "")
_ = a.store.SetMeta("sync_enc_key", "")
a.syncOnline.Store(false)
a.RefreshShell() // 原生账号菜单切换为未登录形态
return nil
}
// ---------- 同步引擎 ----------
// SyncNow 手动触发一次同步,同步完成后返回最新状态。
func (a *App) SyncNow() (SyncStatus, error) {
if e := a.ready(); e != nil {
return SyncStatus{}, e
}
if a.syncUserID() <= 0 {
return SyncStatus{}, errors.New("SYNC_NOT_LOGGED_IN")
}
pushed, pulled, e := a.syncOnce(false)
st, _ := a.GetSyncStatus()
st.Pushed, st.Pulled = pushed, pulled
if e != nil {
return st, e
}
return st, nil
}
// runSyncLoop 启动时立即尝试连接 MySQL 同步一次(在线优先),失败自动降级为本地模式;
// 之后每 5 分钟巡检:连接成功即视为在线,失败静默跳过(断网恢复后自然补同步)。
func (a *App) runSyncLoop() {
if a.bootstrap.State == BootstrapReady && a.syncUserID() > 0 {
_, _, _ = a.syncOnce(true)
}
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-a.ctx.Done():
return
case <-t.C:
if a.bootstrap.State == BootstrapReady && a.syncUserID() > 0 {
_, _, _ = a.syncOnce(true)
}
}
}
}
// syncOnce 执行一轮推送+拉取。silent 模式下网络错误不写消息中心。
func (a *App) syncOnce(silent bool) (pushed, pulled int, err error) {
if !a.syncing.CompareAndSwap(false, true) {
return 0, 0, errors.New("SYNC_IN_PROGRESS")
}
defer a.syncing.Store(false)
userID := a.syncUserID()
if userID <= 0 {
return 0, 0, errors.New("SYNC_NOT_LOGGED_IN")
}
base := a.ctx
if base == nil {
base = context.Background() // 测试等无窗口环境下没有应用级 ctx
}
ctx, cancel := context.WithTimeout(base, 60*time.Second)
defer cancel()
wasOnline := a.syncOnline.Load()
db, e := a.openRemote(ctx)
if e != nil {
a.syncOnline.Store(false)
a.setSyncErr(e.Error())
if !silent {
a.pushMessage("sync", a.syncText("fail"), e.Error(), "", 0, false)
}
a.emitSyncDone()
return 0, 0, e
}
defer db.Close()
a.syncOnline.Store(true)
for _, t := range []syncTable{todoSync, ticketSync, noteSync} {
p, e := a.pushTable(ctx, db, userID, t)
if e != nil {
e = mapSyncErr(e)
a.setSyncErr(e.Error())
a.emitSyncDone()
return pushed, pulled, e
}
pushed += p
g, e := a.pullTable(ctx, db, userID, t)
if e != nil {
e = mapSyncErr(e)
a.setSyncErr(e.Error())
a.emitSyncDone()
return pushed, pulled, e
}
pulled += g
}
// API Key 加密同步(设置里开启后才执行)。
if st, se := a.store.Settings(); se == nil && st.SyncAPIKeys {
kp, kg, ke := a.syncAPIKeys(ctx, db, userID, st)
if ke != nil {
ke = mapSyncErr(ke)
a.setSyncErr(ke.Error())
a.emitSyncDone()
return pushed, pulled, ke
}
pushed += kp
pulled += kg
}
// 头像同步:老服务器缺 sync_settings 表时静默跳过,不影响其余同步。
if ap, ag, ae := a.syncAvatar(ctx, db, userID); ae != nil {
if !isSchemaMissing(ae) {
ae = mapSyncErr(ae)
a.setSyncErr(ae.Error())
a.emitSyncDone()
return pushed, pulled, ae
}
a.store.Log("warning", "同步", "头像同步已跳过", "服务器缺少 sync_settings 表,请重跑 init.sql")
} else {
pushed += ap
pulled += ag
}
// 项目/分组/收藏与排除规则文档同步:缺表同样静默跳过。
// paths:<machineID> 行先于身份文档处理:本机路径先落库,身份合并后待绑定清单才准确。
for _, d := range []struct {
name string
gen func() (string, error)
apply func(string) error
}{
{"paths:" + a.machineID(), a.machinePathsDoc, a.applyMachinePaths},
{"projects", a.projectsDoc, a.applyProjectsDoc},
{"rules", a.rulesDoc, a.applyRulesDoc},
} {
dp, dg, de := a.syncDoc(ctx, db, userID, d.name, d.gen, d.apply)
if de != nil {
if isSchemaMissing(de) {
a.store.Log("warning", "同步", "配置同步已跳过", "服务器缺少 sync_settings 表,请重跑 init.sql")
break
}
de = mapSyncErr(de)
a.setSyncErr(de.Error())
a.emitSyncDone()
return pushed, pulled, de
}
pushed += dp
pulled += dg
}
// 节日背景图同步:管理员(id=1)推送,全员拉取;缺表静默跳过。
if fp, fg, fe := a.syncFestivalImages(ctx, db); fe != nil {
if !isSchemaMissing(fe) {
fe = mapSyncErr(fe)
a.setSyncErr(fe.Error())
a.emitSyncDone()
return pushed, pulled, fe
}
a.store.Log("warning", "同步", "节日图片同步已跳过", "服务器缺少 sync_settings 表,请重跑 init.sql")
} else {
pushed += fp
pulled += fg
}
// 全局文件存储配置:管理员(id=1)推送,全员拉取;缺表静默跳过。
if sp, sg, se := a.syncFileStorageConfig(ctx, db); se != nil {
if !isSchemaMissing(se) {
se = mapSyncErr(se)
a.setSyncErr(se.Error())
a.emitSyncDone()
return pushed, pulled, se
}
a.store.Log("warning", "同步", "文件存储配置同步已跳过", "服务器缺少 sync_settings 表,请重跑 init.sql")
} else {
pushed += sp
pulled += sg
}
// 公开资料推拉 + 团队通知拉取 + 团队指派数缓存:服务器未升级团队表时静默跳过。
a.syncUserProfile(ctx, db, userID)
pulled += a.pullTeamNotices(ctx, db, userID)
a.refreshTeamAssignedCache(ctx, db, userID)
_ = a.store.SetMeta("sync_last_at", nowRFC())
a.setSyncErr("")
if pushed > 0 || pulled > 0 || !wasOnline {
a.store.Log("info", "同步", "同步完成", fmt.Sprintf("推送 %d 条 | 拉取 %d 条", pushed, pulled))
if pushed > 0 || pulled > 0 {
a.pushMessage("sync", a.syncText("done"), fmt.Sprintf(a.syncText("body"), pushed, pulled), "", 0, false)
}
}
a.emitSyncDone()
return pushed, pulled, nil
}
func (a *App) setSyncErr(v string) {
a.syncMu.Lock()
a.syncLastErr = v
a.syncMu.Unlock()
}
func (a *App) emitSyncDone() {
if st, e := a.GetSyncStatus(); e == nil {
a.emit("sync:done", st)
}
a.RefreshTrayStatus()
}
func (a *App) syncText(k string) string {
locale := "zh-CN"
if st, e := a.store.Settings(); e == nil {
locale = st.Locale
}
if locale == "en" {
return map[string]string{"done": "Sync finished", "fail": "Sync failed", "body": "pushed %d, pulled %d"}[k]
}
return map[string]string{"done": "同步完成", "fail": "同步失败", "body": "推送 %d 条,拉取 %d 条"}[k]
}
// ---------- API Key 加密同步(可选) ----------
// ensureEncKey 登录时从远端取(或初始化)每用户加密盐,用登录密码派生 AES 密钥并缓存到本地。
// 服务器只保存盐和密文,无法还原密钥;换设备用同一账号登录即可得到相同密钥。
func (a *App) ensureEncKey(ctx context.Context, db *sql.DB, userID int64, password string) error {
var salt string
e := db.QueryRowContext(ctx, `SELECT value FROM sync_settings WHERE user_id=? AND name='enc_salt'`, userID).Scan(&salt)
if e == sql.ErrNoRows {
if salt, e = newSaltHex(); e != nil {
return e
}
if _, e = db.ExecContext(ctx, `INSERT IGNORE INTO sync_settings(user_id,name,value,updated_at) VALUES(?,?,?,?)`,
userID, "enc_salt", salt, nowRFC()); e != nil {
return e
}
// 并发写入时以真正落库的盐为准。
if e = db.QueryRowContext(ctx, `SELECT value FROM sync_settings WHERE user_id=? AND name='enc_salt'`, userID).Scan(&salt); e != nil {
return e
}
} else if e != nil {
return e
}
raw, e := hex.DecodeString(strings.TrimSpace(salt))
if e != nil || len(raw) < 8 {
return errors.New("SYNC_DECRYPT_FAILED")
}
return a.store.SetMeta("sync_enc_key", hex.EncodeToString(deriveEncKey(password, raw)))
}
// encKey 返回本地缓存的加密密钥;未登录或未派生成功时为 nil。
func (a *App) encKey() []byte {
raw, e := hex.DecodeString(a.store.Meta("sync_enc_key"))
if e != nil || len(raw) != 32 {
return nil
}
return raw
}
// syncAPIKeys 双向同步加密后的 AI API Key单行 JSON 载荷LWW 按 RFC3339 时间戳判定)。
func (a *App) syncAPIKeys(ctx context.Context, db *sql.DB, userID int64, st AppSettings) (pushed, pulled int, err error) {
key := a.encKey()
if key == nil {
return 0, 0, errors.New("SYNC_DECRYPT_FAILED")
}
localAt := a.store.Meta("api_keys_updated_at")
// 开启功能前就配置过 Key 的老用户:补一个本地时间戳,让首轮同步能推送。
if localAt == "" && (st.SparkKey != "" || st.DeepSeekKey != "") {
localAt = nowRFC()
_ = a.store.SetMeta("api_keys_updated_at", localAt)
}
var remoteVal, remoteAt string
e := db.QueryRowContext(ctx, `SELECT value,updated_at FROM sync_settings WHERE user_id=? AND name='api_keys'`, userID).Scan(&remoteVal, &remoteAt)
if e != nil && e != sql.ErrNoRows {
return 0, 0, e
}
hasRemote := e == nil
switch {
case localAt != "" && (!hasRemote || localAt > remoteAt):
payload, e := json.Marshal(map[string]string{"sparkKey": st.SparkKey, "deepSeekKey": st.DeepSeekKey})
if e != nil {
return 0, 0, e
}
enc, e := encryptWithKey(key, string(payload))
if e != nil {
return 0, 0, e
}
if _, e = db.ExecContext(ctx, `INSERT INTO sync_settings(user_id,name,value,updated_at) VALUES(?,?,?,?)
ON DUPLICATE KEY UPDATE value=IF(VALUES(updated_at)>updated_at,VALUES(value),value),
updated_at=IF(VALUES(updated_at)>updated_at,VALUES(updated_at),updated_at)`,
userID, "api_keys", enc, localAt); e != nil {
return 0, 0, e
}
pushed = 1
case hasRemote && remoteAt > localAt:
plain, e := decryptWithKey(key, remoteVal)
if e != nil {
return 0, 0, errors.New("SYNC_DECRYPT_FAILED")
}
var m map[string]string
if e := json.Unmarshal([]byte(plain), &m); e != nil {
return 0, 0, errors.New("SYNC_DECRYPT_FAILED")
}
if m["sparkKey"] != st.SparkKey || m["deepSeekKey"] != st.DeepSeekKey {
st.SparkKey, st.DeepSeekKey = m["sparkKey"], m["deepSeekKey"]
if e := a.store.SaveSettings(st); e != nil {
return 0, 0, e
}
}
// 覆盖 SaveSettings 里自动打的时间戳,与远端保持一致,避免下一轮回推。
_ = a.store.SetMeta("api_keys_updated_at", remoteAt)
pulled = 1
}
return pushed, pulled, nil
}
// syncAvatar 同步头像sync_settings 的 name='avatar' 行,明文 JSON {mode,value}LWW
// path 模式是设备本地行为:既不推送,也不参与拉取覆盖。
func (a *App) syncAvatar(ctx context.Context, db *sql.DB, userID int64) (pushed, pulled int, err error) {
st, e := a.store.Settings()
if e != nil {
return 0, 0, e
}
if st.AvatarMode == "path" {
return 0, 0, nil
}
localAt := a.store.Meta("avatar_updated_at")
// 功能上线前就设置过头像的本地库:补时间戳让首轮能推送。
if localAt == "" && st.AvatarMode != "" {
localAt = nowRFC()
_ = a.store.SetMeta("avatar_updated_at", localAt)
}
var remoteVal, remoteAt string
e = db.QueryRowContext(ctx, `SELECT value,updated_at FROM sync_settings WHERE user_id=? AND name='avatar'`, userID).Scan(&remoteVal, &remoteAt)
if e != nil && e != sql.ErrNoRows {
return 0, 0, e
}
hasRemote := e == nil
switch {
case localAt != "" && (!hasRemote || localAt > remoteAt):
payload, e := json.Marshal(map[string]string{"mode": st.AvatarMode, "value": st.AvatarValue})
if e != nil {
return 0, 0, e
}
if _, e = db.ExecContext(ctx, `INSERT INTO sync_settings(user_id,name,value,updated_at) VALUES(?,?,?,?)
ON DUPLICATE KEY UPDATE value=IF(VALUES(updated_at)>updated_at,VALUES(value),value),
updated_at=IF(VALUES(updated_at)>updated_at,VALUES(updated_at),updated_at)`,
userID, "avatar", string(payload), localAt); e != nil {
return 0, 0, e
}
pushed = 1
case hasRemote && remoteAt > localAt:
var m map[string]string
if json.Unmarshal([]byte(remoteVal), &m) != nil {
return 0, 0, nil // 远端数据异常时忽略,不阻断同步
}
mode := m["mode"]
if mode != "" && mode != "base64" && mode != "url" {
return 0, 0, nil
}
if mode != st.AvatarMode || m["value"] != st.AvatarValue {
st.AvatarMode, st.AvatarValue = mode, m["value"]
if e := a.store.SaveSettings(st); e != nil {
return 0, 0, e
}
}
// 与远端时间戳对齐,避免下一轮把拉取结果再推回去。
_ = a.store.SetMeta("avatar_updated_at", remoteAt)
pulled = 1
}
return pushed, pulled, nil
}
// ---------- 项目/分组/收藏/排除规则文档同步 ----------
//
// 复用 sync_settings 表的两个文档行name='projects' / name='rules'),整文档 LWW
// 本地文档与远端 value 不一致才推送(省去在十几个写入口埋点);
// 远端更新则先套用(项目只增改不删,规则替换自定义部分)再把合并结果回推。
// projectDocEntry 是项目身份文档的元素。v2 起 path 不再入云(跨机器路径互不相同),
// 保留字段用于解析旧客户端推送的 v1 文档(含 path
type projectDocEntry struct {
Name string `json:"name"`
Path string `json:"path,omitempty"`
Description string `json:"description,omitempty"`
Group string `json:"group,omitempty"`
Favorite bool `json:"favorite,omitempty"`
}
type ruleDocEntry struct {
Pattern string `json:"pattern"`
Category string `json:"category"`
}
// projectsDoc 生成本地项目身份文档v2不含本机路径按 name 排序保证内容稳定可比较)。
func (a *App) projectsDoc() (string, error) {
rows, e := a.store.db.Query(`SELECT p.name,p.description,COALESCE(g.name,''),
EXISTS(SELECT 1 FROM favorites f WHERE f.project_id=p.id)
FROM projects p LEFT JOIN project_groups g ON g.id=p.group_id ORDER BY p.name`)
if e != nil {
return "", e
}
defer rows.Close()
list := []projectDocEntry{}
for rows.Next() {
var it projectDocEntry
if e := rows.Scan(&it.Name, &it.Description, &it.Group, &it.Favorite); e != nil {
return "", e
}
list = append(list, it)
}
b, e := json.Marshal(list)
return string(b), e
}
// ensureGroupID 按名取分组 id不存在则创建空名归入默认分组id=1
func (a *App) ensureGroupID(group string) (int64, error) {
gid := int64(1)
g := strings.TrimSpace(group)
if g == "" {
return gid, nil
}
e := a.store.db.QueryRow(`SELECT id FROM project_groups WHERE name=?`, g).Scan(&gid)
if e == sql.ErrNoRows {
now := nowRFC()
r, e2 := a.store.db.Exec(`INSERT INTO project_groups(name,created_at,updated_at) VALUES(?,?,?)`, g, now, now)
if e2 != nil {
return 0, e2
}
gid, _ = r.LastInsertId()
return gid, nil
}
return gid, e
}
// applyProjectsDoc 套用远端项目身份文档:按 name 匹配本地项目并更新描述/分组/收藏;
// 本机没有的项目不再落库(路径属于机器私有,由 paths:<machineID> 行或用户手动绑定补齐),
// 而是记入待绑定清单;远端缺失的本地项目也不删除(避免误删统计数据)。
// 兼容 v1 旧文档:元素含 path 时用它做二次匹配(项目改名场景),但不再采用其路径。
func (a *App) applyProjectsDoc(doc string) error {
var list []projectDocEntry
if json.Unmarshal([]byte(doc), &list) != nil {
return nil // 远端数据异常时忽略,不阻断同步
}
for _, it := range list {
name := strings.TrimSpace(it.Name)
if name == "" {
if p := strings.TrimSpace(it.Path); p != "" {
name = filepath.Base(p)
} else {
continue
}
}
gid, e := a.ensureGroupID(it.Group)
if e != nil {
return e
}
var pid int64
e = a.store.db.QueryRow(`SELECT id FROM projects WHERE name=? LIMIT 1`, name).Scan(&pid)
if e == sql.ErrNoRows {
if p := strings.TrimSpace(it.Path); p != "" {
e = a.store.db.QueryRow(`SELECT id FROM projects WHERE path=? LIMIT 1`, p).Scan(&pid)
}
}
switch {
case e == sql.ErrNoRows:
continue // 本机没有该项目:进待绑定清单(见下方 rebuildCloudPending
case e != nil:
return e
}
if _, e := a.store.db.Exec(`UPDATE projects SET name=?,description=?,group_id=?,updated_at=? WHERE id=? AND (name!=? OR description!=? OR group_id!=?)`,
name, it.Description, gid, nowRFC(), pid, name, it.Description, gid); e != nil {
return e
}
if it.Favorite {
_, _ = a.store.db.Exec(`INSERT OR IGNORE INTO favorites(project_id,created_at) VALUES(?,?)`, pid, nowRFC())
} else {
_, _ = a.store.db.Exec(`DELETE FROM favorites WHERE project_id=?`, pid)
}
}
a.rebuildCloudPending(list)
return nil
}
// rulesDoc 生成本地自定义排除规则文档(内置规则不参与同步)。
func (a *App) rulesDoc() (string, error) {
rows, e := a.store.db.Query(`SELECT pattern,category FROM exclusion_rules WHERE builtin=0 ORDER BY pattern`)
if e != nil {
return "", e
}
defer rows.Close()
list := []ruleDocEntry{}
for rows.Next() {
var it ruleDocEntry
if e := rows.Scan(&it.Pattern, &it.Category); e != nil {
return "", e
}
list = append(list, it)
}
b, e := json.Marshal(list)
return string(b), e
}
// applyRulesDoc 用远端文档整体替换本地自定义规则(内置规则保持不动)。
func (a *App) applyRulesDoc(doc string) error {
var list []ruleDocEntry
if json.Unmarshal([]byte(doc), &list) != nil {
return nil
}
tx, e := a.store.db.Begin()
if e != nil {
return e
}
defer tx.Rollback()
if _, e := tx.Exec(`DELETE FROM exclusion_rules WHERE builtin=0`); e != nil {
return e
}
for _, it := range list {
p := strings.TrimSpace(it.Pattern)
if p == "" {
continue
}
c := it.Category
if c == "" {
c = "custom"
}
// 与内置规则撞名时忽略pattern 全局唯一。
if _, e := tx.Exec(`INSERT OR IGNORE INTO exclusion_rules(pattern,category,builtin) VALUES(?,?,0)`, p, c); e != nil {
return e
}
}
return tx.Commit()
}
// syncDoc 单个文档行的双向同步:远端较新先套用合并,本地与远端内容不一致再回推。
func (a *App) syncDoc(ctx context.Context, db *sql.DB, userID int64, name string, gen func() (string, error), apply func(string) error) (pushed, pulled int, err error) {
doc, e := gen()
if e != nil {
return 0, 0, e
}
var remoteVal, remoteAt string
e = db.QueryRowContext(ctx, `SELECT value,updated_at FROM sync_settings WHERE user_id=? AND name=?`, userID, name).Scan(&remoteVal, &remoteAt)
if e != nil && e != sql.ErrNoRows {
return 0, 0, e
}
hasRemote := e == nil
metaKey := "sync_doc_" + name + "_at"
lastAt := a.store.Meta(metaKey)
if hasRemote && remoteAt > lastAt {
if e := apply(remoteVal); e != nil {
return 0, 0, e
}
if doc, e = gen(); e != nil {
return 0, 0, e
}
lastAt = remoteAt
pulled = 1
}
if (hasRemote && doc != remoteVal) || (!hasRemote && doc != "[]" && doc != "{}") {
now := nowRFC()
if _, e = db.ExecContext(ctx, `INSERT INTO sync_settings(user_id,name,value,updated_at) VALUES(?,?,?,?)
ON DUPLICATE KEY UPDATE value=IF(VALUES(updated_at)>updated_at,VALUES(value),value),
updated_at=IF(VALUES(updated_at)>updated_at,VALUES(updated_at),updated_at)`,
userID, name, doc, now); e != nil {
return 0, 0, e
}
lastAt = now
pushed = 1
}
if lastAt != "" {
_ = a.store.SetMeta(metaKey, lastAt)
}
return pushed, pulled, nil
}
// syncFestivalImages 同步节日背景图:云端行键 fest_img:<节日名>统一挂在管理员id=1名下。
// 管理员账号推送本地 dirty 行;所有账号都从 id=1 拉取,实现"管理员配置、全员可见"。
func (a *App) syncFestivalImages(ctx context.Context, db *sql.DB) (pushed, pulled int, err error) {
const prefix = "fest_img:"
if a.syncUserID() == festivalAdminID {
rows, e := a.store.db.Query(`SELECT fest_key,value,updated_at FROM festival_images WHERE dirty=1`)
if e != nil {
return 0, 0, e
}
type rec struct{ k, v, at string }
recs := []rec{}
for rows.Next() {
var r rec
if e := rows.Scan(&r.k, &r.v, &r.at); e != nil {
rows.Close()
return 0, 0, e
}
recs = append(recs, r)
}
rows.Close()
for _, r := range recs {
if _, e := db.ExecContext(ctx, `INSERT INTO sync_settings(user_id,name,value,updated_at) VALUES(?,?,?,?)
ON DUPLICATE KEY UPDATE value=IF(VALUES(updated_at)>updated_at,VALUES(value),value),
updated_at=IF(VALUES(updated_at)>updated_at,VALUES(updated_at),updated_at)`,
festivalAdminID, prefix+r.k, r.v, r.at); e != nil {
return pushed, pulled, e
}
// 推送期间若管理员又改了同一节日updated_at 变化),保留 dirty 待下轮再推。
if _, e := a.store.db.Exec(`UPDATE festival_images SET dirty=0 WHERE fest_key=? AND updated_at=?`, r.k, r.at); e != nil {
return pushed, pulled, e
}
pushed++
}
}
cursor := a.store.Meta("sync_pull_fest_imgs")
rows, e := db.QueryContext(ctx, `SELECT name,value,updated_at FROM sync_settings
WHERE user_id=? AND name LIKE 'fest\_img:%' AND updated_at>? ORDER BY updated_at LIMIT 500`,
festivalAdminID, cursor)
if e != nil {
return pushed, pulled, e
}
defer rows.Close()
maxSeen := cursor
for rows.Next() {
var name, val, at string
if e := rows.Scan(&name, &val, &at); e != nil {
return pushed, pulled, e
}
if at > maxSeen {
maxSeen = at
}
key := strings.TrimPrefix(name, prefix)
var localAt string
ge := a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key=?`, key).Scan(&localAt)
if ge == nil && localAt >= at {
continue // 本地相同或更新(管理员本机 dirty 行下轮推送)
}
if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES(?,?,?,0)
ON CONFLICT(fest_key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at,dirty=0`, key, val, at); e != nil {
return pushed, pulled, e
}
pulled++
}
if e := rows.Err(); e != nil {
return pushed, pulled, e
}
if maxSeen != cursor {
_ = a.store.SetMeta("sync_pull_fest_imgs", maxSeen)
}
return pushed, pulled, nil
}
// syncFileStorageConfig 同步全局文件存储配置sync_settings 的 name='file_storage' 行,
// 统一挂在管理员 id=1 名下):管理员按 LWW 推送本地修改,所有账号拉取,
// 实现"管理员配置存储方式、全员生效"(见 filestorage.go
func (a *App) syncFileStorageConfig(ctx context.Context, db *sql.DB) (pushed, pulled int, err error) {
localAt := a.store.Meta(fileStorageAtKey)
var remoteVal, remoteAt string
e := db.QueryRowContext(ctx, `SELECT value,updated_at FROM sync_settings WHERE user_id=? AND name=?`,
festivalAdminID, fileStorageKey).Scan(&remoteVal, &remoteAt)
if e != nil && e != sql.ErrNoRows {
return 0, 0, e
}
hasRemote := e == nil
switch {
case a.syncUserID() == festivalAdminID && localAt != "" && (!hasRemote || localAt > remoteAt):
b, e := json.Marshal(a.fileStorageConfig())
if e != nil {
return 0, 0, e
}
if _, e = db.ExecContext(ctx, `INSERT INTO sync_settings(user_id,name,value,updated_at) VALUES(?,?,?,?)
ON DUPLICATE KEY UPDATE value=IF(VALUES(updated_at)>updated_at,VALUES(value),value),
updated_at=IF(VALUES(updated_at)>updated_at,VALUES(updated_at),updated_at)`,
festivalAdminID, fileStorageKey, string(b), localAt); e != nil {
return 0, 0, e
}
pushed = 1
case hasRemote && remoteAt > localAt:
var c FileStorageConfig
if json.Unmarshal([]byte(remoteVal), &c) != nil {
return 0, 0, nil // 远端数据异常时忽略,不阻断同步
}
b, e := json.Marshal(normalizeFileStorage(c))
if e != nil {
return 0, 0, e
}
if e := a.store.SetMeta(fileStorageKey, string(b)); e != nil {
return 0, 0, e
}
// 与远端时间戳对齐,避免管理员本机下一轮把拉取结果再推回去。
_ = a.store.SetMeta(fileStorageAtKey, remoteAt)
pulled = 1
}
return pushed, pulled, nil
}
// syncTable 描述一张可同步表的本地/远端列映射。
type syncTable struct {
local, remote string
// localCols 顺序即 scan/insert 顺序project_id 与 project_name 的转换单独处理。
hasProject bool
hasTimes bool // 有 created_atnotes 没有)
extraCols []string
}
var (
todoSync = syncTable{local: "todos", remote: "sync_todos", hasProject: true, hasTimes: true, extraCols: []string{"title", "content", "due_at", "priority", "status", "history", "team_id"}}
ticketSync = syncTable{local: "tickets", remote: "sync_tickets", hasProject: true, hasTimes: true, extraCols: []string{"title", "description", "type", "start_at", "due_at", "status", "priority", "history", "team_id"}}
noteSync = syncTable{local: "notes", remote: "sync_notes", extraCols: []string{"content"}}
)
// pushTable 上传本地 dirty 行LWW仅当本地 updated_at 更新时覆盖远端)。
func (a *App) pushTable(ctx context.Context, db *sql.DB, userID int64, t syncTable) (int, error) {
cols := append([]string{"uuid"}, t.extraCols...)
if t.hasProject {
cols = append(cols, "project_id")
}
if t.hasTimes {
cols = append(cols, "created_at")
}
cols = append(cols, "updated_at", "deleted")
rows, e := a.store.db.Query(`SELECT ` + strings.Join(cols, ",") + ` FROM ` + t.local + ` WHERE dirty=1`)
if e != nil {
return 0, e
}
type rec struct {
vals []any
uuid string
}
recs := []rec{}
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if e := rows.Scan(ptrs...); e != nil {
rows.Close()
return 0, e
}
recs = append(recs, rec{vals: vals, uuid: asString(vals[0])})
}
rows.Close()
if len(recs) == 0 {
return 0, nil
}
// 远端列uuid + extra + project_name? + created_at? + updated_at + deleted
remoteCols := append([]string{"user_id", "uuid"}, t.extraCols...)
if t.hasProject {
remoteCols = append(remoteCols, "project_name")
}
if t.hasTimes {
remoteCols = append(remoteCols, "created_at")
}
remoteCols = append(remoteCols, "updated_at", "deleted")
// LWW 覆盖:所有列都用 IF(VALUES(updated_at)>updated_at,...)updated_at 必须最后赋值。
set := []string{}
for _, c := range remoteCols[2:] {
if c == "updated_at" {
continue
}
set = append(set, fmt.Sprintf("%s=IF(VALUES(updated_at)>updated_at,VALUES(%s),%s)", c, c, c))
}
set = append(set, "updated_at=IF(VALUES(updated_at)>updated_at,VALUES(updated_at),updated_at)")
q := fmt.Sprintf("INSERT INTO %s(%s) VALUES(%s) ON DUPLICATE KEY UPDATE %s",
t.remote, strings.Join(remoteCols, ","), strings.TrimRight(strings.Repeat("?,", len(remoteCols)), ","), strings.Join(set, ","))
n := 0
for _, r := range recs {
args := []any{userID}
for i, c := range cols {
if c == "project_id" {
args = append(args, a.projectNameByID(asInt64(r.vals[i])))
continue
}
args = append(args, r.vals[i])
}
if _, e := db.ExecContext(ctx, q, args...); e != nil {
return n, e
}
if _, e := a.store.db.Exec(`UPDATE `+t.local+` SET dirty=0 WHERE uuid=? AND dirty=1`, r.uuid); e != nil {
return n, e
}
n++
}
return n, nil
}
// pullTable 增量拉取远端更新LWW远端 updated_at 更新才覆盖本地)。
func (a *App) pullTable(ctx context.Context, db *sql.DB, userID int64, t syncTable) (int, error) {
cursorKey := "sync_pull_" + t.local
cursor := a.store.Meta(cursorKey)
remoteCols := append([]string{"uuid"}, t.extraCols...)
if t.hasProject {
remoteCols = append(remoteCols, "project_name")
}
if t.hasTimes {
remoteCols = append(remoteCols, "created_at")
}
remoteCols = append(remoteCols, "updated_at", "deleted")
rows, e := db.QueryContext(ctx,
`SELECT `+strings.Join(remoteCols, ",")+` FROM `+t.remote+` WHERE user_id=? AND updated_at>? ORDER BY updated_at LIMIT 2000`,
userID, cursor)
if e != nil {
return 0, e
}
defer rows.Close()
n, maxSeen := 0, cursor
for rows.Next() {
vals := make([]any, len(remoteCols))
ptrs := make([]any, len(remoteCols))
for i := range vals {
ptrs[i] = &vals[i]
}
if e := rows.Scan(ptrs...); e != nil {
return n, e
}
m := map[string]any{}
for i, c := range remoteCols {
m[c] = vals[i]
}
updated := asString(m["updated_at"])
if updated > maxSeen {
maxSeen = updated
}
applied, e := a.applyRemoteRow(t, m)
if e != nil {
return n, e
}
if applied {
n++
}
}
if e := rows.Err(); e != nil {
return n, e
}
if maxSeen != cursor {
_ = a.store.SetMeta(cursorKey, maxSeen)
}
return n, nil
}
// applyRemoteRow 将一条远端行按 LWW 合并进本地表。
func (a *App) applyRemoteRow(t syncTable, m map[string]any) (bool, error) {
uuid := asString(m["uuid"])
var localID int64
var localUpdated string
e := a.store.db.QueryRow(`SELECT id,updated_at FROM `+t.local+` WHERE uuid=?`, uuid).Scan(&localID, &localUpdated)
exists := e == nil
remoteUpdated := asString(m["updated_at"])
if exists && remoteUpdated <= localUpdated {
return false, nil // 本地相同或更新dirty 行下轮推送)
}
deleted := asInt64(m["deleted"])
switch t.local {
case "todos":
pid := a.projectIDByName(asString(m["project_name"]))
if exists {
_, e = a.store.db.Exec(`UPDATE todos SET title=?,content=?,project_id=?,due_at=?,priority=?,status=?,history=?,team_id=?,reminded=0,updated_at=?,deleted=?,dirty=0 WHERE id=?`,
asString(m["title"]), asString(m["content"]), pid, asString(m["due_at"]), asString(m["priority"]), asString(m["status"]), asString(m["history"]), asInt64(m["team_id"]), remoteUpdated, deleted, localID)
} else {
_, e = a.store.db.Exec(`INSERT INTO todos(uuid,title,content,project_id,due_at,priority,status,history,team_id,reminded,created_at,updated_at,deleted,dirty) VALUES(?,?,?,?,?,?,?,?,?,0,?,?,?,0)`,
uuid, asString(m["title"]), asString(m["content"]), pid, asString(m["due_at"]), asString(m["priority"]), asString(m["status"]), asString(m["history"]), asInt64(m["team_id"]), asString(m["created_at"]), remoteUpdated, deleted)
}
case "tickets":
pid := a.projectIDByName(asString(m["project_name"]))
if exists {
_, e = a.store.db.Exec(`UPDATE tickets SET title=?,description=?,type=?,project_id=?,start_at=?,due_at=?,status=?,priority=?,history=?,team_id=?,reminded=0,updated_at=?,deleted=?,dirty=0 WHERE id=?`,
asString(m["title"]), asString(m["description"]), asString(m["type"]), pid, asString(m["start_at"]), asString(m["due_at"]), asString(m["status"]), asString(m["priority"]), asString(m["history"]), asInt64(m["team_id"]), remoteUpdated, deleted, localID)
} else {
_, e = a.store.db.Exec(`INSERT INTO tickets(uuid,title,description,type,project_id,start_at,due_at,status,priority,history,team_id,reminded,created_at,updated_at,deleted,dirty) VALUES(?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,0)`,
uuid, asString(m["title"]), asString(m["description"]), asString(m["type"]), pid, asString(m["start_at"]), asString(m["due_at"]), asString(m["status"]), asString(m["priority"]), asString(m["history"]), asInt64(m["team_id"]), asString(m["created_at"]), remoteUpdated, deleted)
}
case "notes":
// 多条笔记:按 uuid 标准 LWW upsert历史单条时代不同设备 uuid 不同,
// 拉取后各成一条笔记,内容互不覆盖)。
if exists {
_, e = a.store.db.Exec(`UPDATE notes SET content=?,updated_at=?,deleted=?,dirty=0 WHERE id=?`,
asString(m["content"]), remoteUpdated, deleted, localID)
} else {
_, e = a.store.db.Exec(`INSERT INTO notes(uuid,content,updated_at,deleted,dirty) VALUES(?,?,?,?,0)`,
uuid, asString(m["content"]), remoteUpdated, deleted)
}
}
return e == nil, e
}
// projectNameByID / projectIDByName 用项目名在设备间映射项目绑定。
func (a *App) projectNameByID(id int64) string {
if id <= 0 {
return ""
}
var name string
_ = a.store.db.QueryRow(`SELECT name FROM projects WHERE id=?`, id).Scan(&name)
return name
}
func (a *App) projectIDByName(name string) int64 {
if name == "" {
return 0
}
var id int64
_ = a.store.db.QueryRow(`SELECT id FROM projects WHERE name=?`, name).Scan(&id)
return id
}
func asString(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case []byte:
return string(x)
default:
return fmt.Sprint(x)
}
}
func asInt64(v any) int64 {
switch x := v.(type) {
case int64:
return x
case []byte:
n, _ := strconv.ParseInt(string(x), 10, 64)
return n
case string:
n, _ := strconv.ParseInt(x, 10, 64)
return n
default:
return 0
}
}