Files
code-utils/sync.go

1094 lines
32 KiB
Go
Raw Normal View History

2026-08-14 07:52:01 +08:00
package main
2026-08-15 17:18:00 +08:00
// sync.go 实现登录 + HTTP 同步引擎(本地优先):
2026-08-14 07:52:01 +08:00
// 推送本地 dirty 行 → 按 updated_at 增量拉取远端 → Last-Write-Wins 冲突合并。
2026-08-15 17:18:00 +08:00
// 远端经 nl-pms-apiJWT本地仍用 SQLite。触发登录成功、每 5 分钟巡检、手动同步。
2026-08-14 07:52:01 +08:00
import (
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
2026-08-15 17:18:00 +08:00
"net/http"
"net/url"
2026-08-14 07:52:01 +08:00
"path/filepath"
"strconv"
"strings"
"time"
)
const syncTimeout = 10 * time.Second
// ---------- 配置与状态 ----------
2026-08-15 17:18:00 +08:00
// syncConfig 解析 API 基址。启动时写入本地 meta见 applyPackagedSyncConfig
2026-08-15 07:29:45 +08:00
// 运行期以 meta 为准meta 为空时回退到打包嵌入配置。
2026-08-14 07:52:01 +08:00
func (a *App) syncConfig() SyncConfig {
2026-08-15 17:18:00 +08:00
c := SyncConfig{BaseURL: strings.TrimRight(strings.TrimSpace(a.store.Meta("sync_base_url")), "/")}
2026-08-15 07:29:45 +08:00
if syncConfigComplete(c) {
return c
}
pkg := packagedSyncDefaults()
if syncConfigComplete(pkg) {
return pkg
}
2026-08-15 17:18:00 +08:00
return SyncConfig{}
2026-08-15 07:29:45 +08:00
}
2026-08-15 17:18:00 +08:00
// applyPackagedSyncConfig 启动时用打包配置覆盖本地 SQLite 中的 API 基址,
// 避免旧安装残留的 MySQL 连接 meta 继续生效。
2026-08-15 07:29:45 +08:00
func (a *App) applyPackagedSyncConfig() {
if a.store == nil {
return
2026-08-14 07:52:01 +08:00
}
2026-08-15 07:29:45 +08:00
pkg := packagedSyncDefaults()
if !syncConfigComplete(pkg) {
return
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
_ = a.store.SetMeta("sync_base_url", strings.TrimRight(strings.TrimSpace(pkg.BaseURL), "/"))
2026-08-14 07:52:01 +08:00
}
func syncConfigComplete(c SyncConfig) bool {
2026-08-15 17:18:00 +08:00
return strings.TrimSpace(c.BaseURL) != ""
2026-08-14 07:52:01 +08:00
}
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
}
2026-08-15 17:18:00 +08:00
u := strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
return a.store.SetMeta("sync_base_url", u)
2026-08-14 07:52:01 +08:00
}
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()),
2026-08-15 17:18:00 +08:00
LoggedIn: a.syncUserID() > 0 && a.store.Meta("sync_access_token") != "",
2026-08-14 07:52:01 +08:00
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
}
// ---------- 注册 / 登录 / 登出 ----------
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
}
2026-08-15 17:18:00 +08:00
if e := a.apiDecode(http.MethodPost, "/api/v1/auth/register", map[string]string{
"username": strings.TrimSpace(username),
"password": password,
}, nil, false); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
a.ensureDefaultAvatar()
return nil
2026-08-14 07:52:01 +08:00
}
func (a *App) SyncLogin(username, password string) (SyncStatus, error) {
if e := a.ready(); e != nil {
return SyncStatus{}, e
}
username = strings.TrimSpace(username)
2026-08-15 17:18:00 +08:00
var out struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
UserID int64 `json:"userId"`
Username string `json:"username"`
}
if e := a.apiDecode(http.MethodPost, "/api/v1/auth/login", map[string]string{
"username": username,
"password": password,
}, &out, false); e != nil {
2026-08-14 07:52:01 +08:00
return SyncStatus{}, e
}
2026-08-15 17:18:00 +08:00
if out.AccessToken == "" || out.UserID <= 0 {
2026-08-14 07:52:01 +08:00
return SyncStatus{}, errors.New("SYNC_BAD_CREDENTIALS")
}
2026-08-15 17:18:00 +08:00
id := out.UserID
if uname := strings.TrimSpace(out.Username); uname != "" {
username = uname
2026-08-14 07:52:01 +08:00
}
// 切换账号时重置拉取游标,并把本地数据整体标脏,合并进新账号。
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`)
}
}
2026-08-15 17:18:00 +08:00
_ = a.store.SetMeta("sync_access_token", out.AccessToken)
_ = a.store.SetMeta("sync_refresh_token", out.RefreshToken)
2026-08-14 07:52:01 +08:00
_ = 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 加密密钥(可选功能,失败不阻断登录,只记录日志)。
2026-08-15 17:18:00 +08:00
if ee := a.ensureEncKey(password); ee != nil {
2026-08-14 07:52:01 +08:00
_ = 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 在线修改登录密码。加密密钥由密码派生,因此会同步轮换:
2026-08-15 17:18:00 +08:00
// 先调 API 改密,再用 HTTP 读写 settings 把 api_keys 密文换成新密钥。
2026-08-14 07:52:01 +08:00
// 其他已登录设备的旧密钥会失效,需用新密码重新登录。
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")
}
2026-08-15 17:18:00 +08:00
if a.store.Meta("sync_access_token") == "" {
return errors.New("SYNC_NOT_LOGGED_IN")
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
if e := a.apiDecode(http.MethodPost, "/api/v1/auth/change-password", map[string]string{
"oldPassword": oldPassword,
"newPassword": newPassword,
}, nil, true); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
// 盐保持不变,新密码 + 原盐派生新密钥;缺盐(从未用过加密同步)时跳过密钥轮换。
var newKey []byte
2026-08-15 17:18:00 +08:00
saltVal, _, saltOK, se := a.apiGetSetting("enc_salt")
if se != nil {
return se
}
if saltOK {
if raw, de := hex.DecodeString(strings.TrimSpace(saltVal)); de == nil && len(raw) >= 8 {
2026-08-14 07:52:01 +08:00
newKey = deriveEncKey(newPassword, raw)
}
}
if newKey != nil {
2026-08-15 17:18:00 +08:00
blob, _, hasKeys, be := a.apiGetSetting("api_keys")
if be != nil {
return be
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
if hasKeys {
2026-08-14 07:52:01 +08:00
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 {
2026-08-15 17:18:00 +08:00
// 旧密钥不可用时用本地明文 Key 重建密文;本地也没有就删除该行(写空并新时间戳无意义,跳过 PUT
2026-08-14 07:52:01 +08:00
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:
enc, ee := encryptWithKey(newKey, plain)
if ee != nil {
return ee
}
2026-08-15 17:18:00 +08:00
// 内容未变只换密文保留原时间戳LWW 语义完全不受影响。
_, at, _, _ := a.apiGetSetting("api_keys")
if at == "" {
at = nowRFC()
}
if e := a.apiPutSetting("api_keys", enc, at); e != nil {
return e
2026-08-14 07:52:01 +08:00
}
case de == nil:
enc, ee := encryptWithKey(newKey, plain)
if ee != nil {
return ee
}
2026-08-15 17:18:00 +08:00
if e := a.apiPutSetting("api_keys", enc, nowRFC()); e != nil {
return e
2026-08-14 07:52:01 +08:00
}
default:
2026-08-15 17:18:00 +08:00
// 无法解密也无法本地重建:用空值覆盖,避免旧密文永久卡死。
if e := a.apiPutSetting("api_keys", "", nowRFC()); e != nil {
return e
2026-08-14 07:52:01 +08:00
}
}
}
_ = 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", "")
2026-08-15 17:18:00 +08:00
_ = a.store.SetMeta("sync_access_token", "")
_ = a.store.SetMeta("sync_refresh_token", "")
2026-08-14 07:52:01 +08:00
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
}
2026-08-15 17:18:00 +08:00
// runSyncLoop 启动时立即尝试同步一次(在线优先),失败自动降级为本地模式;
2026-08-14 07:52:01 +08:00
// 之后每 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()
2026-08-15 17:18:00 +08:00
if userID <= 0 || a.store.Meta("sync_access_token") == "" {
2026-08-14 07:52:01 +08:00
return 0, 0, errors.New("SYNC_NOT_LOGGED_IN")
}
2026-08-15 17:18:00 +08:00
if !syncConfigComplete(a.syncConfig()) {
2026-08-14 07:52:01 +08:00
a.syncOnline.Store(false)
2026-08-15 17:18:00 +08:00
e := errors.New("SYNC_NOT_CONFIGURED")
2026-08-14 07:52:01 +08:00
a.setSyncErr(e.Error())
a.emitSyncDone()
return 0, 0, e
}
2026-08-15 17:18:00 +08:00
wasOnline := a.syncOnline.Load()
2026-08-14 07:52:01 +08:00
for _, t := range []syncTable{todoSync, ticketSync, noteSync} {
2026-08-15 17:18:00 +08:00
p, e := a.pushTable(t)
2026-08-14 07:52:01 +08:00
if e != nil {
2026-08-15 17:18:00 +08:00
a.syncOnline.Store(false)
2026-08-14 07:52:01 +08:00
a.setSyncErr(e.Error())
2026-08-15 17:18:00 +08:00
if !silent {
a.pushMessage("sync", a.syncText("fail"), e.Error(), "", 0, false)
}
2026-08-14 07:52:01 +08:00
a.emitSyncDone()
return pushed, pulled, e
}
pushed += p
2026-08-15 17:18:00 +08:00
g, e := a.pullTable(t)
2026-08-14 07:52:01 +08:00
if e != nil {
2026-08-15 17:18:00 +08:00
a.syncOnline.Store(false)
2026-08-14 07:52:01 +08:00
a.setSyncErr(e.Error())
2026-08-15 17:18:00 +08:00
if !silent {
a.pushMessage("sync", a.syncText("fail"), e.Error(), "", 0, false)
}
2026-08-14 07:52:01 +08:00
a.emitSyncDone()
return pushed, pulled, e
}
pulled += g
}
2026-08-15 17:18:00 +08:00
a.syncOnline.Store(true)
2026-08-14 07:52:01 +08:00
// API Key 加密同步(设置里开启后才执行)。
if st, se := a.store.Settings(); se == nil && st.SyncAPIKeys {
2026-08-15 17:18:00 +08:00
kp, kg, ke := a.syncAPIKeys(st)
2026-08-14 07:52:01 +08:00
if ke != nil {
a.setSyncErr(ke.Error())
a.emitSyncDone()
return pushed, pulled, ke
}
pushed += kp
pulled += kg
}
2026-08-15 17:18:00 +08:00
if ap, ag, ae := a.syncAvatar(); ae != nil {
a.setSyncErr(ae.Error())
a.emitSyncDone()
return pushed, pulled, ae
2026-08-14 07:52:01 +08:00
} 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},
} {
2026-08-15 17:18:00 +08:00
dp, dg, de := a.syncDoc(d.name, d.gen, d.apply)
2026-08-14 07:52:01 +08:00
if de != nil {
a.setSyncErr(de.Error())
a.emitSyncDone()
return pushed, pulled, de
}
pushed += dp
pulled += dg
}
2026-08-15 17:18:00 +08:00
if fp, fg, fe := a.syncFestivalImages(); fe != nil {
a.setSyncErr(fe.Error())
a.emitSyncDone()
return pushed, pulled, fe
2026-08-14 07:52:01 +08:00
} else {
pushed += fp
pulled += fg
}
2026-08-15 17:18:00 +08:00
if sp, sg, se := a.syncFileStorageConfig(); se != nil {
a.setSyncErr(se.Error())
a.emitSyncDone()
return pushed, pulled, se
2026-08-14 07:52:01 +08:00
} else {
pushed += sp
pulled += sg
}
2026-08-15 17:18:00 +08:00
a.syncUserProfile()
pulled += a.pullTeamNotices()
a.refreshTeamAssignedCache()
2026-08-14 07:52:01 +08:00
_ = 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)
}
2026-08-14 17:54:06 +08:00
a.RefreshTrayStatus()
2026-08-14 07:52:01 +08:00
}
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 密钥并缓存到本地。
2026-08-15 17:18:00 +08:00
func (a *App) ensureEncKey(password string) error {
salt, _, ok, e := a.apiGetSetting("enc_salt")
if e != nil {
return e
}
if !ok {
2026-08-14 07:52:01 +08:00
if salt, e = newSaltHex(); e != nil {
return e
}
2026-08-15 17:18:00 +08:00
if e = a.apiPutSetting("enc_salt", salt, nowRFC()); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
// 并发写入时以真正落库的盐为准。
2026-08-15 17:18:00 +08:00
salt, _, ok, e = a.apiGetSetting("enc_salt")
if e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
if !ok {
return errors.New("SYNC_DECRYPT_FAILED")
}
2026-08-14 07:52:01 +08:00
}
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 时间戳判定)。
2026-08-15 17:18:00 +08:00
func (a *App) syncAPIKeys(st AppSettings) (pushed, pulled int, err error) {
2026-08-14 07:52:01 +08:00
key := a.encKey()
if key == nil {
return 0, 0, errors.New("SYNC_DECRYPT_FAILED")
}
localAt := a.store.Meta("api_keys_updated_at")
if localAt == "" && (st.SparkKey != "" || st.DeepSeekKey != "") {
localAt = nowRFC()
_ = a.store.SetMeta("api_keys_updated_at", localAt)
}
2026-08-15 17:18:00 +08:00
remoteVal, remoteAt, hasRemote, e := a.apiGetSetting("api_keys")
if e != nil {
2026-08-14 07:52:01 +08:00
return 0, 0, e
}
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
}
2026-08-15 17:18:00 +08:00
if e = a.apiPutSetting("api_keys", enc, localAt); e != nil {
2026-08-14 07:52:01 +08:00
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
}
}
_ = a.store.SetMeta("api_keys_updated_at", remoteAt)
pulled = 1
}
return pushed, pulled, nil
}
2026-08-15 17:18:00 +08:00
// syncAvatar 同步头像settings name='avatar',明文 JSON {mode,value}LWW
2026-08-14 07:52:01 +08:00
// path 模式是设备本地行为:既不推送,也不参与拉取覆盖。
2026-08-15 17:18:00 +08:00
func (a *App) syncAvatar() (pushed, pulled int, err error) {
2026-08-14 07:52:01 +08:00
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)
}
2026-08-15 17:18:00 +08:00
remoteVal, remoteAt, hasRemote, e := a.apiGetSetting("avatar")
if e != nil {
2026-08-14 07:52:01 +08:00
return 0, 0, e
}
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
}
2026-08-15 17:18:00 +08:00
if e = a.apiPutSetting("avatar", string(payload), localAt); e != nil {
2026-08-14 07:52:01 +08:00
return 0, 0, e
}
pushed = 1
case hasRemote && remoteAt > localAt:
var m map[string]string
if json.Unmarshal([]byte(remoteVal), &m) != nil {
2026-08-15 17:18:00 +08:00
return 0, 0, nil
2026-08-14 07:52:01 +08:00
}
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
}
// ---------- 项目/分组/收藏/排除规则文档同步 ----------
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"`
}
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
}
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
}
func (a *App) applyProjectsDoc(doc string) error {
var list []projectDocEntry
if json.Unmarshal([]byte(doc), &list) != nil {
2026-08-15 17:18:00 +08:00
return nil
2026-08-14 07:52:01 +08:00
}
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:
2026-08-15 17:18:00 +08:00
continue
2026-08-14 07:52:01 +08:00
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
}
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
}
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"
}
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 单个文档行的双向同步:远端较新先套用合并,本地与远端内容不一致再回推。
2026-08-15 17:18:00 +08:00
func (a *App) syncDoc(name string, gen func() (string, error), apply func(string) error) (pushed, pulled int, err error) {
2026-08-14 07:52:01 +08:00
doc, e := gen()
if e != nil {
return 0, 0, e
}
2026-08-15 17:18:00 +08:00
remoteVal, remoteAt, hasRemote, e := a.apiGetSetting(name)
if e != nil {
2026-08-14 07:52:01 +08:00
return 0, 0, e
}
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()
2026-08-15 17:18:00 +08:00
if e = a.apiPutSetting(name, doc, now); e != nil {
2026-08-14 07:52:01 +08:00
return 0, 0, e
}
lastAt = now
pushed = 1
}
if lastAt != "" {
_ = a.store.SetMeta(metaKey, lastAt)
}
return pushed, pulled, nil
}
2026-08-15 17:18:00 +08:00
// syncFestivalImages 同步节日背景图:云端键 fest_img:<节日名>。
// 管理员推送本地 dirty 行所有账号拉取API 会从管理员名下读全局前缀)。
func (a *App) syncFestivalImages() (pushed, pulled int, err error) {
2026-08-14 07:52:01 +08:00
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 {
2026-08-15 17:18:00 +08:00
if e := a.apiPutSettingWithStepUp(prefix+r.k, r.v, r.at, true); e != nil {
// 无动态码时跳过推送,保留 dirty待管理员验证后再同步
if e.Error() == "ADMIN_STEPUP_REQUIRED" || e.Error() == "ADMIN_IP_CHANGED" {
continue
}
2026-08-14 07:52:01 +08:00
return pushed, pulled, e
}
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++
}
}
2026-08-15 17:18:00 +08:00
items, e := a.apiListSettings(prefix)
2026-08-14 07:52:01 +08:00
if e != nil {
return pushed, pulled, e
}
2026-08-15 17:18:00 +08:00
cursor := a.store.Meta("sync_pull_fest_imgs")
2026-08-14 07:52:01 +08:00
maxSeen := cursor
2026-08-15 17:18:00 +08:00
for _, it := range items {
at := it.UpdatedAt
if at <= cursor {
continue
2026-08-14 07:52:01 +08:00
}
if at > maxSeen {
maxSeen = at
}
2026-08-15 17:18:00 +08:00
key := strings.TrimPrefix(it.Name, prefix)
2026-08-14 07:52:01 +08:00
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 {
2026-08-15 17:18:00 +08:00
continue
2026-08-14 07:52:01 +08:00
}
if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES(?,?,?,0)
2026-08-15 17:18:00 +08:00
ON CONFLICT(fest_key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at,dirty=0`, key, it.Value, at); e != nil {
2026-08-14 07:52:01 +08:00
return pushed, pulled, e
}
pulled++
}
if maxSeen != cursor {
_ = a.store.SetMeta("sync_pull_fest_imgs", maxSeen)
}
return pushed, pulled, nil
}
2026-08-15 17:18:00 +08:00
// syncFileStorageConfig 同步全局文件存储配置settings name='file_storage')。
func (a *App) syncFileStorageConfig() (pushed, pulled int, err error) {
2026-08-14 07:52:01 +08:00
localAt := a.store.Meta(fileStorageAtKey)
2026-08-15 17:18:00 +08:00
remoteVal, remoteAt, hasRemote, e := a.apiGetGlobalSetting(fileStorageKey)
if e != nil {
2026-08-14 07:52:01 +08:00
return 0, 0, e
}
switch {
case a.syncUserID() == festivalAdminID && localAt != "" && (!hasRemote || localAt > remoteAt):
b, e := json.Marshal(a.fileStorageConfig())
if e != nil {
return 0, 0, e
}
2026-08-15 17:18:00 +08:00
if e = a.apiPutSettingWithStepUp(fileStorageKey, string(b), localAt, true); e != nil {
if e.Error() == "ADMIN_STEPUP_REQUIRED" || e.Error() == "ADMIN_IP_CHANGED" {
return 0, 0, nil
}
2026-08-14 07:52:01 +08:00
return 0, 0, e
}
pushed = 1
case hasRemote && remoteAt > localAt:
var c FileStorageConfig
if json.Unmarshal([]byte(remoteVal), &c) != nil {
2026-08-15 17:18:00 +08:00
return 0, 0, nil
2026-08-14 07:52:01 +08:00
}
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
}
2026-08-15 17:18:00 +08:00
// syncTable 描述一张可同步表的本地列映射。
2026-08-14 07:52:01 +08:00
type syncTable struct {
2026-08-15 17:18:00 +08:00
local string
// localCols 顺序即 scan 顺序project_id 与 project_name 的转换单独处理。
2026-08-14 07:52:01 +08:00
hasProject bool
hasTimes bool // 有 created_atnotes 没有)
extraCols []string
}
var (
2026-08-15 17:18:00 +08:00
todoSync = syncTable{local: "todos", hasProject: true, hasTimes: true, extraCols: []string{"title", "content", "due_at", "priority", "status", "history", "team_id"}}
ticketSync = syncTable{local: "tickets", hasProject: true, hasTimes: true, extraCols: []string{"title", "description", "type", "start_at", "due_at", "status", "priority", "history", "team_id"}}
noteSync = syncTable{local: "notes", extraCols: []string{"content"}}
2026-08-14 07:52:01 +08:00
)
2026-08-15 17:18:00 +08:00
// pushTable 收集本地 dirty 行POST /sync/push 后清 dirty。
func (a *App) pushTable(t syncTable) (int, error) {
2026-08-14 07:52:01 +08:00
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
}
2026-08-15 17:18:00 +08:00
payload := make([]map[string]any, 0, len(recs))
uuids := make([]string, 0, len(recs))
2026-08-14 07:52:01 +08:00
for _, r := range recs {
2026-08-15 17:18:00 +08:00
m := map[string]any{}
2026-08-14 07:52:01 +08:00
for i, c := range cols {
if c == "project_id" {
2026-08-15 17:18:00 +08:00
m["project_name"] = a.projectNameByID(asInt64(r.vals[i]))
2026-08-14 07:52:01 +08:00
continue
}
2026-08-15 17:18:00 +08:00
if c == "deleted" || c == "team_id" {
m[c] = asInt64(r.vals[i])
continue
}
m[c] = asString(r.vals[i])
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
payload = append(payload, m)
uuids = append(uuids, r.uuid)
}
var resp struct {
Pushed int `json:"pushed"`
}
if e := a.apiDecode(http.MethodPost, "/api/v1/sync/push", map[string]any{
"table": t.local,
"rows": payload,
}, &resp, true); e != nil {
return 0, e
}
for _, u := range uuids {
if _, e := a.store.db.Exec(`UPDATE `+t.local+` SET dirty=0 WHERE uuid=? AND dirty=1`, u); e != nil {
return 0, e
2026-08-14 07:52:01 +08:00
}
}
2026-08-15 17:18:00 +08:00
return len(uuids), nil
2026-08-14 07:52:01 +08:00
}
// pullTable 增量拉取远端更新LWW远端 updated_at 更新才覆盖本地)。
2026-08-15 17:18:00 +08:00
func (a *App) pullTable(t syncTable) (int, error) {
2026-08-14 07:52:01 +08:00
cursorKey := "sync_pull_" + t.local
cursor := a.store.Meta(cursorKey)
2026-08-15 17:18:00 +08:00
q := "/api/v1/sync/pull?table=" + url.QueryEscape(t.local) + "&cursor=" + url.QueryEscape(cursor)
var resp struct {
Rows []map[string]any `json:"rows"`
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
if e := a.apiDecode(http.MethodGet, q, nil, &resp, true); e != nil {
2026-08-14 07:52:01 +08:00
return 0, e
}
n, maxSeen := 0, cursor
2026-08-15 17:18:00 +08:00
for _, m := range resp.Rows {
2026-08-14 07:52:01 +08:00
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 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 {
2026-08-15 17:18:00 +08:00
return false, nil
2026-08-14 07:52:01 +08:00
}
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":
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
}
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
2026-08-15 17:18:00 +08:00
case float64:
return int64(x)
case json.Number:
n, _ := x.Int64()
return n
2026-08-14 07:52:01 +08:00
case []byte:
n, _ := strconv.ParseInt(string(x), 10, 64)
return n
case string:
n, _ := strconv.ParseInt(x, 10, 64)
return n
default:
return 0
}
}