Compare commits

..
2 Commits
Author SHA1 Message Date
skycurtain 34a7823e1a feat(web): 根路径与未知路径默认进入成员端
管理端入口形态待定,当前经地址栏 /admin 进入;交互设计访问拓扑同步。
2026-10-10 16:47:01 +08:00
skycurtain ea4efbe1d7 feat(deploy): 一条命令链部署——双单文件 + Caddy + DATABASE_URL 单源
多阶段镜像编出 server/engine 单文件与前端静态;compose 起 pg/server/engine/caddy;
启动时自动迁移;仅用 DATABASE_URL,postgres 建库参数由 entrypoint 拆解。
2026-10-10 16:46:54 +08:00
15 changed files with 264 additions and 24 deletions
+17
View File
@@ -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
+11 -3
View File
@@ -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=
+1 -1
View File
@@ -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` 后填写)。
+37
View File
@@ -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
+56 -4
View File
@@ -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 编码
## 文档地图
| 文档 | 什么时候读 | 状态 |
+9 -1
View File
@@ -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({
+9 -1
View File
@@ -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,
+2 -2
View File
@@ -17,10 +17,10 @@ declare module "vue-router" {
export const router: Router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/admin/overview" },
{ path: "/", redirect: "/member" },
...adminRoutes,
...memberRoutes,
{ path: "/:pathMatch(.*)*", redirect: "/admin/overview" },
{ path: "/:pathMatch(.*)*", redirect: "/member" },
],
});
+17
View File
@@ -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
}
}
+12
View File
@@ -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 "$@"
+19
View File
@@ -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
+64 -8
View File
@@ -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:
+3 -3
View File
@@ -16,9 +16,9 @@
(拓扑见下文描述,实现时以此为准)
```
- **管理面**(作者专属):入口为登录页(账号密码)→ 侧边栏布局;空库时按三态分流(见《交互设计-登录与会话》)
- **成员视图**(成员专属):入口为登录壳(令牌)→ 侧边栏布局(与管理端同构壳,菜单三项:总览 / 调用日志 / 支付参数);与管理面物理隔离(两套路由、两套凭证中间件,令牌进不了管理面,会话进不了成员视图)
- **没有第三种入口**:无开放查询页、无游客态;任何人第一眼看到的必然是两个登录入口之一
- **管理面**(作者专属):入口为登录页(账号密码)→ 侧边栏布局;空库时按三态分流(见《交互设计-登录与会话》);地址栏访问 `/admin`(根路径不指向此处)
- **成员视图**(成员专属):入口为登录壳(令牌)→ 侧边栏布局(与管理端同构壳,菜单三项:总览 / 调用日志 / 支付参数);与管理面物理隔离(两套路由、两套凭证中间件,令牌进不了管理面,会话进不了成员视图);**根路径 `/` 默认进入本世界**(未知路径同样回退到此)
- **没有第三种入口**:无开放查询页、无游客态、无分发页;根地址即成员入口。管理面的入口形态待定(当前经地址栏 `/admin` 进入)
- 401 分流统一约定:管理面任意请求 401 → 登录页(提示「会话已过期」);成员视图任意请求 401 → 登录壳(提示「令牌无效或已停用」)——各自回各自的家,不交叉
## 2. 管理面导航:侧边栏
+1 -1
View File
@@ -39,7 +39,7 @@ GET 管理面入口
## 4. 成员登录壳
- 成员视图的唯一入口:令牌输入框(单字段)+ 进入按钮
- **唯一入口**:令牌输入框(单字段)+ 进入按钮;**根路径 `/` 默认落到本页**(已持令牌则进成员总览);管理面入口形态(是否在界面露出)待定,当前经地址栏 `/admin` 进入
- 提交 → 服务端哈希比对 + `status` 检查:
- 通过 → 令牌存 sessionStorage → 进入成员视图(侧边栏布局,落地「总览」,见《交互设计-成员视图》)
- 失败/停用 → 「令牌无效或已停用」(同一提示,不泄露停用与错误的区别——成员侧无需区分,反正都是找作者)
+6
View File
@@ -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<typeof createDb>;
/** 启动时按 journal 应用缺失迁移(幂等);`migrationsFolder` 指向 drizzle/ 目录 */
export async function runMigrations(db: Db, migrationsFolder: string) {
await migrate(db, { migrationsFolder });
}
export * from "./schema.ts";
export * from "./repos.ts";