diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..749c471 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.env +.env.* +!.env.example +node_modules +**/node_modules +**/dist +**/.tmp +**/.vite +*.log +api*.json +session-archive +coverage +*.tsbuildinfo +.claude +.agents +agent diff --git a/.env.example b/.env.example index 9733b78..386e913 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,15 @@ -# ── new-api-key-usage · 本地环境变量示例 ────────────────────────── +# ── new-api-key-usage · 环境变量示例 ────────────────────────── # 复制为 .env 后填写(.env 已被 .gitignore,勿提交) +# 开发:任意可用的 PostgreSQL,DATABASE_URL 指向即可 +# 部署:docker compose up -d --build(见 README) -# 本服务端口(避开 3000/3001 等常见占用) +# 本服务端口(避开 3000/3001 等常见占用;容器内固定 18456 即可) PORT=18456 -# PostgreSQL 连接串(开发 compose 默认如下) +# PostgreSQL 连接串——数据库配置的唯一来源 +# 开发:指向你本机/已有的库(主机 127.0.0.1 或 localhost) +# 部署:同一份即可;compose 会把主机改写为容器服务名 postgres +# 密码含 @ : / 等字符时请 URL 编码 DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/new_api_key_usage # 已登记 sk-key 的加密密钥:≥32 字节随机数,base64url。 @@ -24,6 +29,9 @@ COOKIE_SECURE=false # 业务日志级别:debug | info | warning | error | fatal(访问日志另由 hono/logger 输出) LOG_LEVEL=info +# 同步引擎周期(毫秒,≥1000;默认 300000 = 5 分钟) +# SYNC_INTERVAL_MS=300000 + # 空库双通道:可选。用户名与密码同时存在时,首次 setup-state 静默落库; # 只填一个视为未配置。此后改 env 不改密;改密走管理面表单。 # ADMIN_INIT_USERNAME= diff --git a/AGENTS.md b/AGENTS.md index 15ee35a..661d0b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun` | `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) | | `bun run dev:web` | 开发跑前端 Vite | | `bun run build:web` | 前端类型检查 + 产物构建 | -| `docker compose up -d` | 起开发用 PostgreSQL | +| `docker compose up -d --build` | 部署一键起(server / engine / caddy / pg) | 环境变量见 `.env.example`(复制为 `.env` 后填写)。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbc8bf6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# 语法:docker/dockerfile:1 +# 构建产物:/app/naku-server · /app/naku-engine · /app/drizzle · /srv(前端) + +# ---------- 构建:依赖 + 前端 + 两个单文件 ---------- +FROM oven/bun:1.4-alpine AS build +WORKDIR /src + +COPY package.json bun.lock ./ +COPY apps ./apps +COPY packages ./packages + +RUN bun install --frozen-lockfile + +# 前端静态资源 +RUN bun run --cwd apps/web build-only + +# 单文件(linux-x64;构建发生在 Linux 容器内,无需交叉编译) +RUN mkdir -p /out \ + && bun build --compile --outfile /out/naku-server apps/server/src/index.ts \ + && bun build --compile --outfile /out/naku-engine apps/engine/src/index.ts + +# ---------- 应用运行时:两个二进制 + 迁移 SQL ---------- +FROM alpine:3.20 AS app +WORKDIR /app +# Bun 单文件仍需 C++ 运行时与 CA;从构建镜像拷贝,避免 apk 联网 +COPY --from=build /usr/lib/libstdc++.so.6 /usr/lib/libgcc_s.so.1 /usr/lib/ +COPY --from=build /etc/ssl/certs /etc/ssl/certs +COPY --from=build /out/naku-server /out/naku-engine /app/ +COPY --from=build /src/packages/db/drizzle /app/drizzle +ENV MIGRATIONS_FOLDER=/app/drizzle +# 默认跑 server;compose 里 engine 覆盖 command +CMD ["/app/naku-server"] + +# ---------- 网关:Caddy + 前端静态资源 ---------- +FROM caddy:2-alpine AS proxy +COPY deploy/Caddyfile /etc/caddy/Caddyfile +COPY --from=build /src/apps/web/dist /srv diff --git a/README.md b/README.md index b066415..7b04b1a 100644 --- a/README.md +++ b/README.md @@ -64,13 +64,12 @@ packages/ ## 快速开始 -前置:Bun、Docker(开发用 PostgreSQL)。 +前置:Bun、一个可用的 PostgreSQL(本机已有或别处均可)。 ```bash bun install -docker compose up -d # 起 PostgreSQL -cp .env.example .env # 按注释填写 -bun run db:push # 推送 schema +cp .env.example .env # DATABASE_URL 指向你已有的 PostgreSQL +bun run db:push # 开发期推送 schema bun run dev:server # 后端(默认 18456) bun run dev:engine # 同步引擎(另开终端) bun run dev:web # 前端 Vite(5175) @@ -80,6 +79,59 @@ bun run dev:web # 前端 Vite(5175) 更多命令与协作约定见 [`AGENTS.md`](AGENTS.md)。 +## 部署 + +生产形态:**Caddy**(前端静态 + `/api` 反代)+ **server / engine 单文件**(Bun `--compile`)+ PostgreSQL;启动时自动应用 `packages/db/drizzle` 迁移。开发不依赖本 Compose——把 `DATABASE_URL` 指到任意可用 PostgreSQL 即可。 + +### 起服务 + +```bash +cp .env.example .env +# 必改:DATABASE_URL、SK_KEY_ENCRYPTION_SECRET、NEWAPI_BASE、COOKIE_SECURE +docker compose up -d --build +``` + +- 页面:`http://<服务器>/`;接口文档:`http://<服务器>/api/docs` +- 升级:`git pull` 后重复 `docker compose up -d --build`(改表随启动迁移) + +### 环境变量要点 + +| 变量 | 说明 | +|------|------| +| `DATABASE_URL` | 数据库配置的**唯一来源**(用户 / 密码 / 库名都在串里)。开发指向本机库;部署同一份即可,compose 会把主机改写为容器服务名 `postgres` | +| `SK_KEY_ENCRYPTION_SECRET` | ≥32 字节随机数(base64url)。**生成一次、入库后不可更换**——丢失则已登记 key 无法解密,与库同等备份 | +| `NEWAPI_BASE` | 上游 NewAPI 实例根地址 | +| `COOKIE_SECURE` | HTTPS / 域名用 `true`;纯 IP + HTTP 用 `false`(否则登录后立刻 401) | +| `ADMIN_INIT_USERNAME` / `ADMIN_INIT_PASSWORD` | 可选;同时存在时空库首次静默建管理员 | + +### 服务分工 + +| 服务 | 职责 | 对外 | +|------|------|------| +| `caddy` | 前端静态 + `/api` 反代 | 仅 80 / 443 | +| `server` | 管理面 / 成员视图 API | 内网 | +| `engine` | 轮询上游、去重入库 | 内网 | +| `postgres` | 数据(卷 `pgdata`) | 内网 | + +### HTTPS + +把 `deploy/Caddyfile` 站点行从 `:80` 改成域名(如 `your.domain.example`),重建后 Caddy 自动签证书;同时设 `COOKIE_SECURE=true`。 + +### 常用命令 + +```bash +docker compose ps # 运行状态 +docker compose logs -f engine # 看同步 +docker compose restart server # 只重启后端 +docker compose down # 全停(数据在卷里,不丢) +``` + +### 注意 + +1. `COOKIE_SECURE` 必须与访问方式匹配:HTTP + `true` 会「登录后立刻 401」 +2. `SK_KEY_ENCRYPTION_SECRET` 更换等于已登记 key 全部作废 +3. `DATABASE_URL` 密码含 `@` `:` `/` 等字符时做 URL 编码 + ## 文档地图 | 文档 | 什么时候读 | 状态 | diff --git a/apps/engine/src/index.ts b/apps/engine/src/index.ts index db6f47f..9894b26 100644 --- a/apps/engine/src/index.ts +++ b/apps/engine/src/index.ts @@ -1,5 +1,6 @@ +import { fileURLToPath } from "node:url"; import { appLogger, setupLogging } from "@new-api-key-usage/core"; -import { createDb, createRepos } from "@new-api-key-usage/db"; +import { createDb, createRepos, runMigrations } from "@new-api-key-usage/db"; import { loadEnv } from "./env.ts"; import { createSyncLoop } from "./loop.ts"; @@ -8,6 +9,13 @@ setupLogging(env.LOG_LEVEL); const log = appLogger("engine"); const db = createDb(env.DATABASE_URL); + +const migrationsFolder = + process.env.MIGRATIONS_FOLDER ?? + fileURLToPath(new URL("../../../packages/db/drizzle", import.meta.url)); +await runMigrations(db, migrationsFolder); +log.info("数据库迁移已就绪"); + const repos = createRepos(db); const loop = createSyncLoop({ diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 656441d..ff5b6db 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,6 +1,7 @@ +import { fileURLToPath } from "node:url"; import { createApp } from "./app.ts"; import { loadEnv } from "./env.ts"; -import { createDb } from "@new-api-key-usage/db"; +import { createDb, runMigrations } from "@new-api-key-usage/db"; import { appLogger, setupLogging } from "@new-api-key-usage/core"; const env = loadEnv(process.env); @@ -8,6 +9,13 @@ setupLogging(env.LOG_LEVEL); const db = createDb(env.DATABASE_URL); +const migrationsFolder = + process.env.MIGRATIONS_FOLDER ?? + fileURLToPath(new URL("../../../packages/db/drizzle", import.meta.url)); + +await runMigrations(db, migrationsFolder); +appLogger("server").info("数据库迁移已就绪"); + const app = createApp({ db, skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET, diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..4c46baa --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,17 @@ +# 站点地址按部署环境改: +# 本地/内网 HTTP → :80 +# 公网 HTTPS → your.domain.example(Caddy 自动签证书) +:80 { + root * /srv + + # 接口反代到 server 容器 + handle /api/* { + reverse_proxy server:18456 + } + + # SPA:非 /api 的路径回退 index.html + handle { + try_files {path} /index.html + file_server + } +} diff --git a/deploy/entrypoint-app.sh b/deploy/entrypoint-app.sh new file mode 100644 index 0000000..57cd077 --- /dev/null +++ b/deploy/entrypoint-app.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# 把 DATABASE_URL 的主机改写为 compose 服务名 postgres,再启动应用 +set -e + +if [ -z "$DATABASE_URL" ]; then + echo "DATABASE_URL is required" >&2 + exit 1 +fi + +export DATABASE_URL="$(echo "$DATABASE_URL" | sed -E 's#(@)[^:/]+(:[0-9]+)?(/)#\1postgres\2\3#')" + +exec "$@" diff --git a/deploy/entrypoint-pg.sh b/deploy/entrypoint-pg.sh new file mode 100644 index 0000000..a1fcb27 --- /dev/null +++ b/deploy/entrypoint-pg.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# 从 DATABASE_URL 拆出官方镜像建库所需的 POSTGRES_*,再交回原入口 +set -e + +if [ -z "$DATABASE_URL" ]; then + echo "DATABASE_URL is required" >&2 + exit 1 +fi + +u="${DATABASE_URL#*://}" +auth="${u%%@*}" +rest="${u#*@}" + +export POSTGRES_USER="${auth%%:*}" +export POSTGRES_PASSWORD="${auth#*:}" +export POSTGRES_DB="${rest#*/}" +export POSTGRES_DB="${POSTGRES_DB%%\?*}" + +exec docker-entrypoint.sh postgres diff --git a/docker-compose.yml b/docker-compose.yml index ba8f0dd..776d4d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,76 @@ +# 部署一键起:docker compose up -d --build +# 配置只认 DATABASE_URL(见 .env.example);开发不依赖本文件 +# 注意:PASSWORD 含 @ : / 等字符时需 URL 编码 + services: postgres: image: postgres:17-alpine - container_name: new-api-key-usage-pg + entrypoint: ["/bin/sh", "/entrypoint-pg.sh"] environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: new_api_key_usage - ports: - - "5432:5432" + DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL in .env} volumes: + - ./deploy/entrypoint-pg.sh:/entrypoint-pg.sh:ro - pgdata:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d new_api_key_usage"] + test: ["CMD-SHELL", "pg_isready -U $$(echo $$DATABASE_URL | sed -E 's#^[^:]+://([^:]+):.*#\\1#')"] interval: 5s timeout: 3s - retries: 10 + retries: 12 + restart: unless-stopped + + server: + build: + context: . + target: app + entrypoint: ["/bin/sh", "/entrypoint-app.sh"] + command: ["/app/naku-server"] + environment: + PORT: "18456" + DATABASE_URL: ${DATABASE_URL} + SK_KEY_ENCRYPTION_SECRET: ${SK_KEY_ENCRYPTION_SECRET:?set SK_KEY_ENCRYPTION_SECRET in .env} + NEWAPI_BASE: ${NEWAPI_BASE:?set NEWAPI_BASE in .env} + COOKIE_SECURE: ${COOKIE_SECURE:-true} + LOG_LEVEL: ${LOG_LEVEL:-info} + ADMIN_INIT_USERNAME: ${ADMIN_INIT_USERNAME:-} + ADMIN_INIT_PASSWORD: ${ADMIN_INIT_PASSWORD:-} + MIGRATIONS_FOLDER: /app/drizzle + volumes: + - ./deploy/entrypoint-app.sh:/entrypoint-app.sh:ro + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + + engine: + build: + context: . + target: app + entrypoint: ["/bin/sh", "/entrypoint-app.sh"] + command: ["/app/naku-engine"] + environment: + DATABASE_URL: ${DATABASE_URL} + SK_KEY_ENCRYPTION_SECRET: ${SK_KEY_ENCRYPTION_SECRET} + NEWAPI_BASE: ${NEWAPI_BASE} + SYNC_INTERVAL_MS: ${SYNC_INTERVAL_MS:-300000} + LOG_LEVEL: ${LOG_LEVEL:-info} + MIGRATIONS_FOLDER: /app/drizzle + volumes: + - ./deploy/entrypoint-app.sh:/entrypoint-app.sh:ro + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + + caddy: + build: + context: . + target: proxy + ports: + - "80:80" + - "443:443" + depends_on: + - server + restart: unless-stopped volumes: pgdata: diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 8e004b6..927a09f 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,4 +1,5 @@ import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; import * as schema from "./schema.ts"; @@ -9,5 +10,10 @@ export function createDb(connectionString: string) { export type Db = ReturnType; +/** 启动时按 journal 应用缺失迁移(幂等);`migrationsFolder` 指向 drizzle/ 目录 */ +export async function runMigrations(db: Db, migrationsFolder: string) { + await migrate(db, { migrationsFolder }); +} + export * from "./schema.ts"; export * from "./repos.ts";