diff --git a/Dockerfile b/Dockerfile index 3e4edf5..595ff12 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,14 @@ COPY frontend/index.html ./index.html RUN npm ci && npm run build FROM golang:1.26.0-bookworm AS backend +ARG APP_VERSION=0.2.0 WORKDIR /src/backend COPY backend/go.mod backend/go.sum* ./ RUN go mod download COPY backend/cmd ./cmd COPY backend/internal ./internal COPY --from=frontend /src/backend/internal/web/dist ./internal/web/dist -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/codex-helper ./cmd/server +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X codex-helper/internal/app.Version=${APP_VERSION}" -o /out/codex-helper ./cmd/server FROM node:24.19.0-bookworm-slim AS codex ARG CODEX_VERSION=0.147.0 diff --git a/backend/CONTRACT.md b/backend/CONTRACT.md index 9a07e02..98bb26a 100644 --- a/backend/CONTRACT.md +++ b/backend/CONTRACT.md @@ -43,7 +43,7 @@ | 方法与路径 | 鉴权 | 行为 | | --- | --- | --- | -| `GET /api/v1/system/status` | 匿名 | `200 {initialized,version,appServer}`。当前版本字段为 `0.2.0`。 | +| `GET /api/v1/system/status` | 匿名 | `200 {initialized,version,appServer}`。`version` 为镜像构建时注入的应用版本,未注入时默认为 `0.2.0`。 | | `POST /api/v1/setup` | 匿名、仅未初始化 | body `{username,password,timezone}`;用户名至少 3 位、密码至少 10 位,否则 400;时区有效时写入,否则使用默认 UTC。事务创建唯一管理员和通用设置,设置 session,返回 `201 {ok:true}`;已初始化返回 409。 | | `POST /api/v1/auth/login` | 匿名 | body `{username,password}`;未初始化返回 409,错误凭据返回 401,成功设置 session 并返回 `200 {ok:true}`,限流返回 429。 | | `任意方法 /api/v1/auth/me` | session;非 `GET`/`HEAD` 还需来源头 | `200 {username}`。前端使用 `GET`。 | diff --git a/backend/internal/app/api.go b/backend/internal/app/api.go index 480d734..d727d36 100644 --- a/backend/internal/app/api.go +++ b/backend/internal/app/api.go @@ -32,7 +32,7 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) { } } a.mu.RUnlock() - jsonOut(w, 200, map[string]any{"initialized": a.store.Initialized(), "version": "0.2.0", "appServer": connected}) + jsonOut(w, 200, map[string]any{"initialized": a.store.Initialized(), "version": Version, "appServer": connected}) return } if p == "setup" && r.Method == "POST" { diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 783eb6b..bc6cfcd 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -25,6 +25,9 @@ import ( webassets "codex-helper/internal/web" ) +// Version is overridden at build time for release images. +var Version = "0.2.0" + type App struct { dataDir string store *store.Store diff --git a/backend/internal/app/runtime_test.go b/backend/internal/app/runtime_test.go index 2f40fab..5a9a208 100644 --- a/backend/internal/app/runtime_test.go +++ b/backend/internal/app/runtime_test.go @@ -23,6 +23,28 @@ func TestSystemStatusRejectsNonGETMethods(t *testing.T) { } } +func TestSystemStatusReturnsBuildVersion(t *testing.T) { + a := newReminderTestApp(t) + originalVersion := Version + Version = "1.2.3-test" + t.Cleanup(func() { Version = originalVersion }) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil) + a.api(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + var body struct { + Version string `json:"version"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Version != "1.2.3-test" { + t.Fatalf("version = %q; want %q", body.Version, "1.2.3-test") + } +} + func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) { a := newReminderTestApp(t) a.runtimes[1] = &accountRuntime{} diff --git a/docs/frontend/application.md b/docs/frontend/application.md index f740584..ca32651 100644 --- a/docs/frontend/application.md +++ b/docs/frontend/application.md @@ -4,7 +4,7 @@ ## 状态与路由 -应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。这些分支只负责交互,服务端 session 才是安全边界。 +应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。状态响应中的构建版本以 `v` 徽标显示在安装页、登录页和登录后侧栏的品牌区域。这些分支只负责交互,服务端 session 才是安全边界。 主题和当前账号 ID 保存到 `localStorage`。总览先加载账号列表,为当前账号加载 Dashboard,并每 30 秒刷新内存数据;切换账号必须清空旧 Dashboard,避免短暂展示另一账号信息。手动刷新调用账号级 sync 后重新读取 Dashboard。 diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index 3293d22..a77eef6 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -4,10 +4,22 @@ ## 镜像结构 -`Dockerfile` 有四个阶段:Node 24.19.0 构建 React 静态资源;Go 1.26.0 以 `CGO_ENABLED=0` 构建后端;Node 阶段安装固定 `@openai/codex`;最终 Debian bookworm 镜像只包含 CA、时区、后端、Node runtime 和 Codex 包。 +`Dockerfile` 有四个阶段:Node 24.19.0 构建 React 静态资源;Go 1.26.0 以 `CGO_ENABLED=0` 构建后端;Node 阶段安装固定 `@openai/codex`;最终 Debian bookworm 镜像只包含 CA、时区、后端、Node runtime 和 Codex 包。`APP_VERSION` 构建参数通过 Go linker 注入状态 API,未指定时默认为 `0.2.0`,前端在品牌区域显示该版本。 前端产物复制到 `backend/internal/web/dist` 后嵌入二进制。运行层使用 UID `10001` 的 system 用户 `helper`,默认 `DATA_DIR=/data`、`LISTEN_ADDR=:8080`,并暴露 `/data` volume 和 8080。健康检查调用二进制自身的 `healthcheck` 子命令。 +## 发布镜像 + +仓库根目录的 `push-image.sh` 构建 `linux/amd64`、`linux/arm64` 镜像并推送至 Docker Hub。版本号必须形如 `0.3.0` 或 `0.3.0-beta.1`,同时作为应用版本和镜像标签;脚本还会更新 `latest`: + +```bash +docker login -u koalalove +bash push-image.sh 0.3.0 +# 完整重新构建:bash push-image.sh 0.3.0 --no-cache +``` + +脚本会创建并选用 `codex-helper-builder` buildx builder,并尝试通过 `tonistiigi/binfmt` 注册跨架构模拟器。发布前应先完成本地镜像验证;脚本执行成功即会推送远端标签。 + ## Compose 与持久化 `docker-compose.yml` 本地构建 `codex-helper:latest`,将宿主机 8180 映射到容器 8080,并把命名卷 `codex-helper-data` 挂载到 `/data`。全部数据库、密钥、Codex 配置和多账号凭据都依赖这个卷。 diff --git a/frontend/index.html b/frontend/index.html index c41b5d7..8416ba8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1 +1,12 @@ -
+ + + + + + Codex Helper + + +
+ + + diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e4dceb1..2dfa2cc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -90,6 +90,7 @@ function App() { if (!s.initialized) return ( { setS({ ...s, initialized: true }); setAuth(true); @@ -100,9 +101,15 @@ function App() { {auth ? ( - setAuth(false)} />} /> + setAuth(false)} />} + /> ) : ( - setAuth(true)} />} /> + setAuth(true)} />} + /> )} @@ -116,7 +123,15 @@ const Splash = () => (
); -function Setup({ onDone }: { onDone: () => void }) { +function Brand({ version }: { version?: string }) { + return ( +
+ Codex Helper + {version && v{version}} +
+ ); +} +function Setup({ version, onDone }: { version: string; onDone: () => void }) { const [form, set] = useState({ username: "admin", password: "", @@ -135,9 +150,7 @@ function Setup({ onDone }: { onDone: () => void }) { return (
-
- Codex Helper -
+

连接你的 Codex
@@ -146,6 +159,9 @@ function Setup({ onDone }: { onDone: () => void }) {

一个容器内完成用量洞察、重置提醒与账户管理。

+
+ +
首次初始化 · 1 / 1

创建管理员

); } -function Login({ done }: { done: () => void }) { +function Login({ version, done }: { version: string; done: () => void }) { const [u, setU] = useState("admin"), [p, setP] = useState(""), [e, setE] = useState(""); @@ -199,9 +215,7 @@ function Login({ done }: { done: () => void }) { } }} > -
- Codex Helper -
+

欢迎回来