diff --git a/README.md b/README.md index 590b2a1..9729539 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ http://服务器地址:8180 4. 点击页面显示的 OpenAI 验证地址,或在另一台设备的浏览器中打开该地址。 5. 登录需要监控的 ChatGPT/Codex 账户。 6. 输入页面显示的一次性设备码并确认授权。 -7. 返回 Codex Helper,等待数秒后进入“用量总览”,点击“刷新全部”或对应账号卡片中的“刷新账号”。 +7. 返回 Codex Helper,服务器会在后台自动同步账号用量;首次同步通常会在数秒内完成,之后固定每 5 分钟同步一次。 8. 页面显示账户邮箱、套餐和限额窗口后,即表示连接成功。 设备码授权在浏览器中完成,适用于 Docker、NAS 和远程服务器。默认连接的登录凭据保存在 `/data/codex`,新增连接保存在 `/data/accounts/<账号 ID>/codex`,不会写入浏览器或项目源码。 @@ -72,7 +72,7 @@ http://服务器地址:8180 进入“设置中心” → “通用”,可设置: - 时区:用于初始化配置;界面时间按浏览器本地时区显示 -- 同步间隔:1–60 分钟 +- 自动同步:服务器固定每 5 分钟同步全部账号 - 历史保留时间:30、60、90、180 或 365 天 - 提前提醒时间:重置前 1–1440 分钟 - 是否发送重置前提醒和重置后确认;重置后确认也会通过额度百分比回落识别并提醒官方活动、临时补发等提前重置 @@ -191,7 +191,7 @@ docker compose restart codex-helper ### 完成设备码授权后仍显示“尚未连接” -- 等待几秒后点击“刷新全部”或对应账号卡片中的“刷新账号”。 +- 等待首次后台同步完成;之后服务器每 5 分钟自动同步一次。若持续未连接,请检查 app-server 和网络日志。 - 确认授权的是需要监控的 ChatGPT 账户。 - 重新进入 Codex 设置,退出账户后再次生成设备码。 - 检查 `docker compose logs -f codex-helper` 中是否存在网络或认证错误。 diff --git a/backend/CONTRACT.md b/backend/CONTRACT.md index d672ff9..3473cb8 100644 --- a/backend/CONTRACT.md +++ b/backend/CONTRACT.md @@ -80,7 +80,7 @@ {timezone,theme,syncMinutes,retentionDays,beforeMinutes,notifyBefore,notifyAfter} ``` -任意非 `GET` 方法都按更新处理,前端使用 `PUT`;请求接受完整对象。`syncMinutes` 为 1–60,`retentionDays` 为 30–365,`beforeMinutes` 为 1–1440,时区必须能由 Go 加载;非法值返回 400。成功返回保存后的对象。 +任意非 `GET` 方法都按更新处理,前端使用 `PUT`;请求接受完整对象。`syncMinutes` 为兼容旧客户端保留的字段,服务端始终按 5 分钟自动同步并返回 `5`;`retentionDays` 为 30–365,`beforeMinutes` 为 1–1440,时区必须能由 Go 加载;非法值返回 400。成功返回保存后的对象。 ### SMTP diff --git a/backend/internal/app/api.go b/backend/internal/app/api.go index 1838d21..5acc6a5 100644 --- a/backend/internal/app/api.go +++ b/backend/internal/app/api.go @@ -408,9 +408,9 @@ func (a *App) logout(w http.ResponseWriter, r *http.Request) { func (a *App) general() GeneralSettings { g := defaults() a.store.GetJSON("general", &g) - if g.SyncMinutes < 1 { - g.SyncMinutes = 5 - } + // Keep the legacy field in the response for old clients, but automatic + // account synchronization is intentionally fixed at five minutes. + g.SyncMinutes = automaticSyncMinutes if g.RetentionDays < 1 { g.RetentionDays = 90 } @@ -430,6 +430,7 @@ func (a *App) generalAPI(w http.ResponseWriter, r *http.Request) { jsonOut(w, 400, map[string]string{"error": "无效时区"}) return } + g.SyncMinutes = automaticSyncMinutes _ = a.store.SetJSON("general", g) jsonOut(w, 200, g) } diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index b8d2dc6..5e64de4 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -52,6 +52,8 @@ type accountRuntime struct { stopped bool } +const automaticSyncInterval = time.Duration(automaticSyncMinutes) * time.Minute + type codexClient interface { Start(context.Context) error Initialize(context.Context) error @@ -267,17 +269,18 @@ func (a *App) syncAll(ctx context.Context) { } } func (a *App) scheduler() { - t := time.NewTicker(time.Minute) - defer t.Stop() + maintenanceTicker := time.NewTicker(time.Minute) + syncTicker := time.NewTicker(automaticSyncInterval) + defer maintenanceTicker.Stop() + defer syncTicker.Stop() for { select { case <-a.ctx.Done(): return - case <-t.C: + case <-syncTicker.C: + a.syncAll(context.Background()) + case <-maintenanceTicker.C: g := a.general() - if time.Now().Unix()%(int64(g.SyncMinutes)*60) < 60 { - a.syncAll(context.Background()) - } _, _ = a.store.Cleanup(g.RetentionDays) go a.processReminders() } diff --git a/backend/internal/app/runtime_test.go b/backend/internal/app/runtime_test.go index 8ad5ad9..2d6286a 100644 --- a/backend/internal/app/runtime_test.go +++ b/backend/internal/app/runtime_test.go @@ -349,6 +349,19 @@ func TestPeakDailyTokensUsesOnlyTheCurrentTokenCycle(t *testing.T) { } } +func TestAutomaticSyncIntervalIsFixedAtFiveMinutes(t *testing.T) { + a := newReminderTestApp(t) + if err := a.store.SetJSON("general", GeneralSettings{SyncMinutes: 60, RetentionDays: 90, BeforeMinutes: 30}); err != nil { + t.Fatal(err) + } + if got := a.general().SyncMinutes; got != automaticSyncMinutes { + t.Fatalf("sync minutes = %d; want %d", got, automaticSyncMinutes) + } + if automaticSyncInterval != 5*time.Minute { + t.Fatalf("automatic sync interval = %s; want 5m", automaticSyncInterval) + } +} + func TestFlattenLimitReadsAppServerWindowDuration(t *testing.T) { var limit rawLimit if err := json.Unmarshal([]byte(`{ diff --git a/backend/internal/app/types.go b/backend/internal/app/types.go index b3903c8..9f6a8b6 100644 --- a/backend/internal/app/types.go +++ b/backend/internal/app/types.go @@ -9,6 +9,9 @@ type GeneralSettings struct { NotifyBefore bool `json:"notifyBefore"` NotifyAfter bool `json:"notifyAfter"` } + +const automaticSyncMinutes = 5 + type SMTPSettings struct { Host string `json:"host"` Port int `json:"port"` @@ -92,5 +95,5 @@ type Dashboard struct { } func defaults() GeneralSettings { - return GeneralSettings{Timezone: "UTC", Theme: "system", SyncMinutes: 5, RetentionDays: 90, BeforeMinutes: 30, NotifyBefore: true, NotifyAfter: true} + return GeneralSettings{Timezone: "UTC", Theme: "system", SyncMinutes: automaticSyncMinutes, RetentionDays: 90, BeforeMinutes: 30, NotifyBefore: true, NotifyAfter: true} } diff --git a/docs/backend/runtime-and-api.md b/docs/backend/runtime-and-api.md index 1bc0119..8a3fc5a 100644 --- a/docs/backend/runtime-and-api.md +++ b/docs/backend/runtime-and-api.md @@ -2,7 +2,7 @@ ## 启动与关闭 -入口为 `backend/cmd/server/main.go`。普通启动调用 `app.New`:打开 `/data/codex-helper.db` 并执行兼容迁移、打开或创建 `/data/secret.key`、为数据库中的每个账号创建运行时对象,再组装 HTTP server。`Run` 启动 app-server 保活、每分钟调度器和 Telegram long polling;SIGINT/SIGTERM 触发五秒 HTTP 优雅关闭、停止所有账号进程并关闭数据库。 +入口为 `backend/cmd/server/main.go`。普通启动调用 `app.New`:打开 `/data/codex-helper.db` 并执行兼容迁移、打开或创建 `/data/secret.key`、为数据库中的每个账号创建运行时对象,再组装 HTTP server。`Run` 启动 app-server 保活、后台调度器和 Telegram long polling;SIGINT/SIGTERM 触发五秒 HTTP 优雅关闭、停止所有账号进程并关闭数据库。 `codex-helper healthcheck` 将 `${LISTEN_ADDR:-:8080}` 的通配地址转换为 `127.0.0.1` 并请求 `/health/live`,供 Docker `HEALTHCHECK` 使用。 @@ -14,12 +14,12 @@ - `internal/store/store.go`:SQLite schema、兼容迁移和数据方法。 - `internal/codex/client.go`:与 `codex app-server` 的 JSONL 请求/响应关联。 -所有路由由 `http.ServeMux` 承载。API 先处理三个匿名入口,再统一调用 `require`;前端资源从 Go `embed.FS` 提供,未知浏览器路径回退到 `index.html`。完整端点以 [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 为准。 +所有路由由 `http.ServeMux` 承载。API 先处理 status、setup、login 和初始化后的账号/Dashboard 匿名只读入口,再统一调用 `require`;前端资源从 Go `embed.FS` 提供,未知浏览器路径回退到 `index.html`。完整端点以 [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 为准。 ## 后台任务 - `keepCodex` 每秒检查未就绪的账号,串行完成进程启动与协议初始化,成功后立即同步。 -- `scheduler` 每分钟按 `syncMinutes` 的 Unix 时间取模触发全账号同步,清理过期历史,并异步处理提醒。 +- `scheduler` 每五分钟固定触发全账号同步;每分钟清理过期历史并异步处理提醒。通用设置中的 `syncMinutes` 仅为旧客户端兼容字段,不改变固定调度周期。 - `telegramLoop` 使用 Bot API long polling;仅已配置 Token 才请求更新。 - app-server 的登录、账号和限额通知会触发带短退避的同步;多次失败将内存 Dashboard 标为 stale 并记录 `lastError`。 diff --git a/docs/frontend/application.md b/docs/frontend/application.md index 0af9f4e..fb6bf6e 100644 --- a/docs/frontend/application.md +++ b/docs/frontend/application.md @@ -6,7 +6,7 @@ 应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;初始化后未登录渲染公开只读 `/` 总览和 `/login` 登录页,登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未登录访问 `/settings` 回到公开总览,未知路径回到 `/`。状态响应中的构建版本以 `v` 徽标显示在安装页、登录页、公开总览和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。 -主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后刷新;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。总览默认只展示账号摘要,展开卡片后显示完整限额、统计和 Token 图;顶部“刷新全部”和卡片内的账号级刷新都会先调用对应的 sync,再重新读取 Dashboard。 +主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后重新读取;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。公开总览使用匿名只读接口并隐藏身份配置字段,隐藏手动刷新按钮和设置导航;服务器固定每 5 分钟同步全部账号,登录后总览默认只展示账号摘要,展开卡片后显示完整限额、统计和 Token 图。 桌面端使用固定侧栏,`800px` 及以下改为紧凑顶栏和固定底部主导航;GitHub、主题和退出位于顶栏辅助菜单。移动布局最低支持 320px,使用动态视口高度和 CSS safe-area 环境变量避开 iOS 浏览器工具栏与设备安全区。内容必须为底部导航保留空间,表单控件避免 iOS 聚焦缩放,主要交互保持至少 44px 触控区域。 diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f59ee9d..47e7510 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -12,7 +12,6 @@ import { import { Activity, Bell, - Check, ChevronDown, Clock, Coins, @@ -24,7 +23,6 @@ import { LogOut, Moon, MoreHorizontal, - RefreshCw, Settings, Sun, Trash2, @@ -378,22 +376,19 @@ type DashboardCardState = { dashboard: Dash | null; loading: boolean; error: string; - refresh: "idle" | "loading" | "done"; }; const emptyDashboardCardState = (): DashboardCardState => ({ dashboard: null, loading: false, error: "", - refresh: "idle", }); function Dashboard({ publicView = false }: { publicView?: boolean }) { const [accounts, setAccounts] = useState(null), [cards, setCards] = useState>({}), [expanded, setExpanded] = useState>({}), - [error, setError] = useState(""), - [refresh, setRefresh] = useState<"idle" | "loading" | "done">("idle"); + [error, setError] = useState(""); const requestRef = useRef(new Map()), activeControllersRef = useRef(new Map()); @@ -410,11 +405,7 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) { })); }; - const loadDashboard = async ( - accountId: number, - signal?: AbortSignal, - manual = false, - ) => { + const loadDashboard = async (accountId: number, signal?: AbortSignal) => { const request = (requestRef.current.get(accountId) || 0) + 1; requestRef.current.set(accountId, request); let ownController: AbortController | undefined; @@ -427,7 +418,6 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) { updateCard(accountId, { loading: true, error: "", - ...(manual ? { refresh: "loading" as const } : {}), }); try { const dashboard = await get( @@ -444,26 +434,13 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) { dashboard, loading: false, error: "", - ...(manual ? { refresh: "done" as const } : {}), }); - if (manual) { - window.setTimeout( - () => - setCards((current) => { - const card = current[accountId]; - if (!card || card.refresh !== "done") return current; - return { ...current, [accountId]: { ...card, refresh: "idle" } }; - }), - 2000, - ); - } } catch (value) { if (signal.aborted || requestRef.current.get(accountId) !== request) return; updateCard(accountId, { loading: false, error: toErrorMessage(value), - ...(manual ? { refresh: "idle" as const } : {}), }); } finally { if ( @@ -516,42 +493,6 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) { [], ); - const syncAccount = async (accountId: number) => { - try { - setError(""); - await post(`accounts/${accountId}/sync`, decodeOK, {}, undefined, 60_000); - await loadDashboard(accountId, undefined, true); - } catch (value) { - updateCard(accountId, { - loading: false, - refresh: "idle", - error: toErrorMessage(value), - }); - } - }; - const syncAll = async () => { - if (!accounts?.length) return; - setRefresh("loading"); - setError(""); - const results = await Promise.allSettled( - accounts.map(async (account) => { - await post( - `accounts/${account.id}/sync`, - decodeOK, - {}, - undefined, - 60_000, - ); - await loadDashboard(account.id, undefined, true); - }), - ); - const failed = results.filter((result) => result.status === "rejected"); - if (failed.length) - setError(`${failed.length} 个账号刷新失败,请展开对应卡片查看详情。`); - setRefresh(failed.length ? "idle" : "done"); - if (!failed.length) window.setTimeout(() => setRefresh("idle"), 2000); - }; - if (accounts === null) return ( <> @@ -582,21 +523,8 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) { <>
- -
+ sub={`${publicView ? "公开只读 · " : ""}共 ${accounts.length} 个账号,服务器每 5 分钟自动同步`} + /> {error &&
{error}
}
{accounts.map((account) => ( @@ -612,7 +540,6 @@ function Dashboard({ publicView = false }: { publicView?: boolean }) { [account.id]: !current[account.id], })) } - onRefresh={() => void syncAccount(account.id)} /> ))}
@@ -626,14 +553,12 @@ function AccountOverviewCard({ publicView, expanded, onToggle, - onRefresh, }: { account: Account; state: DashboardCardState; publicView: boolean; expanded: boolean; onToggle: () => void; - onRefresh: () => void; }) { const dashboard = state.dashboard; const email = dashboard?.account.email ?? account.email; @@ -675,7 +600,6 @@ function AccountOverviewCard({ ) : ( @@ -684,11 +608,6 @@ function AccountOverviewCard({

{state.error || "正在读取账号用量…"}

- {state.error && ( - - )} )} @@ -739,12 +658,10 @@ function AccountQuickSummary({ function AccountDashboardDetails({ dashboard, state, - onRefresh, publicView, }: { dashboard: Dash; state: DashboardCardState; - onRefresh: () => void; publicView: boolean; }) { return ( @@ -764,18 +681,6 @@ function AccountDashboardDetails({ ? new Date(dashboard.fetchedAt * 1000).toLocaleString() : "尚未同步"} - {(state.error || dashboard.lastError) && ( @@ -821,7 +726,9 @@ function AccountDashboardDetails({ {!dashboard.limits.length && (
- 该连接暂无限额数据,请确认登录后刷新。 + {publicView + ? "该连接暂无限额数据" + : "该连接暂无限额数据,服务器会自动同步。"}
)} {dashboard.currentCycle && ( @@ -1111,16 +1018,10 @@ function General() { - +
+ 自动同步 + 服务器每 5 分钟自动刷新所有账号 +