feat: remove redis service from lite deployment

This commit is contained in:
JetSprow
2026-04-30 09:18:05 +10:00
parent 153e3954c6
commit 4efa3401ca
8 changed files with 33 additions and 189 deletions

View File

@@ -17,9 +17,6 @@ SUBSCRIPTION_URL=""
# Must be at least 32 bytes, used for AES-256-GCM encryption
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef"
# Redis connection URL for local tools; Docker Compose overrides host to redis
REDIS_URL="redis://localhost:6379"
# Optional GeoIP MMDB path. The repository includes a default City database at data/GeoLite2-City.mmdb.
GEOIP_MMDB_PATH="data/GeoLite2-City.mmdb"

View File

@@ -12,8 +12,8 @@ J-Board 的定位很明确:它不是新的节点控制面,也不替代 3x-ui
用户浏览器 / 客户端订阅
Next.js App Router 面板
├─ PostgreSQL用户、订单、套餐、订阅、审计、风控事件
├─ Redis限流、后台任务与缓存辅助
├─ SQLite:用户、订单、套餐、订阅、审计、风控事件
├─ 进程内限流:无需 Redis 单独容器
├─ 3x-ui API同步入站、开通/暂停/删除代理客户端
└─ Agent API接收 jboard-agent 上报的延迟、路由、节点真实连接日志
@@ -73,9 +73,8 @@ J-Board 面板和 Agent 使用相对独立的版本节奏。
## 技术栈
- Next.js 16 App Router + React 19
- Prisma 7 + PostgreSQL 16
- Prisma 7 + SQLite
- NextAuth 4 Credentials
- Redis 7
- Tailwind CSS 4 + Base UI + Sonner + Recharts
- Nodemailer SMTP 邮件
- MaxMind MMDB GeoIP
@@ -118,9 +117,7 @@ J-Board 面板和 Agent 使用相对独立的版本节奏。
| `SUBSCRIPTION_URL` | 订阅访问地址 | 可选。用于生成客户端订阅链接,例如 `https://sub.example.com`;留空时复用 `NEXTAUTH_URL`。 |
| `NEXTAUTH_SECRET` | 登录会话密钥 | 生产环境必须使用随机长字符串。 |
| `ENCRYPTION_KEY` | 敏感信息加密密钥 | 至少 32 字节。生产使用后不要更换,否则 3x-ui 密码、探测 Token、SMTP 密码、流媒体凭据等已加密数据会无法解密。 |
| `DATABASE_URL` | PostgreSQL 连接 | 本地工具使用Docker 部署时 Compose 会覆盖为容器内数据库地址。 |
| `POSTGRES_PASSWORD` | Docker PostgreSQL 密码 | 一键脚本会自动生成。 |
| `REDIS_URL` | Redis 连接 | 本地工具使用Docker 部署时 Compose 会覆盖为容器内 Redis 地址。 |
| `DATABASE_URL` | SQLite 文件地址 | 本地默认 `file:./storage/jboard.db`Docker 部署时 Compose 会覆盖为容器内 `/app/storage/jboard.db`。 |
| `GEOIP_MMDB_PATH` | GeoIP 城市库 | 默认 `data/GeoLite2-City.mmdb`。可换成自己的 MaxMind City MMDB。 |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_NAME` | 初始管理员 | 首次 `db:seed` 创建管理员账号。已有数据库不会强制重置旧管理员密码。 |
@@ -144,7 +141,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board/main/script
| 订阅访问地址 | 用于生成 Clash/V2rayN/Shadowrocket 等客户端订阅链接。可以和网站地址相同,也可以填独立订阅域名,例如 `https://sub.example.com`。 |
| 本机监听端口 | 默认 `3000`。Nginx、Caddy、宝塔或 1Panel 的反代目标就是 `http://127.0.0.1:3000`。 |
| 管理员邮箱和密码 | 首次初始化会创建该管理员,脚本完成后会再次打印。 |
| PostgreSQL 密码、`NEXTAUTH_SECRET``ENCRYPTION_KEY` | 可手动输入;回车会自动生成安全值。 |
| `NEXTAUTH_SECRET``ENCRYPTION_KEY` | 可手动输入;回车会自动生成安全值。 |
也可以通过环境变量覆盖默认行为:
@@ -170,7 +167,7 @@ APP_DIR=/opt/jboard GH_REPO=JetSprow/J-Board BRANCH=main bash <(curl -fsSL https
```bash
cp .env.example .env
# 编辑 .env尤其是 NEXTAUTH_URL、SUBSCRIPTION_URL、NEXTAUTH_SECRET、ENCRYPTION_KEY、POSTGRES_PASSWORD、管理员账号
# 编辑 .env尤其是 NEXTAUTH_URL、SUBSCRIPTION_URL、NEXTAUTH_SECRET、ENCRYPTION_KEY、管理员账号
docker compose build init app
docker compose --profile setup run --rm init
docker compose up -d app
@@ -444,7 +441,7 @@ shasum -a 256 jboard-agent-linux-amd64 jboard-agent-linux-arm64 > SHA256SUMS
- 不要提交 `.env`、探测 Token、3x-ui 密码、SMTP 密码、支付密钥。
- 数据库备份包含用户、订单、支付配置、节点凭据和邮件配置,建议加密保存并限制下载权限。
- 生产环境不要公开 PostgreSQL 和 Redis 端口。
- 生产环境不要公开 SQLite 文件、备份文件或管理接口。
- 3x-ui 面板建议限制来源 IP 或使用反向代理鉴权。
- `ENCRYPTION_KEY` 一旦生产使用不要随意更换。
- 管理后台账号建议使用强密码和专用邮箱。

View File

@@ -6,15 +6,11 @@ services:
restart: unless-stopped
ports:
- "${APP_PORT:-3000}:3000"
depends_on:
redis:
condition: service_healthy
volumes:
- sqlite_data:/app/storage
env_file: .env
environment:
- DATABASE_URL=file:/app/storage/jboard.db
- REDIS_URL=redis://redis:6379
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/public/app-info || exit 1"]
interval: 30s
@@ -36,20 +32,5 @@ services:
profiles:
- setup
redis:
image: redis:7-alpine
restart: unless-stopped
volumes:
- redis_data:/data
# 仅暴露给内部网络,生产环境不要对外开放
# ports:
# - "6379:6379"
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 5s
timeout: 5s
retries: 5
volumes:
sqlite_data:
redis_data:

80
package-lock.json generated
View File

@@ -17,7 +17,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"ioredis": "^5.10.1",
"lucide-react": "^1.8.0",
"maxmind": "^5.0.6",
"next": "16.2.4",
@@ -2053,12 +2052,6 @@
}
}
},
"node_modules/@ioredis/commands": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
"integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
"license": "MIT"
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -5043,15 +5036,6 @@
"node": ">=6"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/code-block-writer": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
@@ -5638,6 +5622,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
@@ -7686,30 +7671,6 @@
"node": ">=12"
}
},
"node_modules/ioredis": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "1.5.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -8839,18 +8800,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -11267,27 +11216,6 @@
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
@@ -12134,12 +12062,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",

View File

@@ -20,7 +20,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"ioredis": "^5.10.1",
"lucide-react": "^1.8.0",
"maxmind": "^5.0.6",
"next": "16.2.4",

View File

@@ -14,7 +14,6 @@ SITE_NAME=""
ADMIN_EMAIL=""
ADMIN_PASSWORD=""
ADMIN_NAME=""
POSTGRES_PASSWORD=""
NEXTAUTH_SECRET=""
ENCRYPTION_KEY=""
ENV_REUSED="0"
@@ -285,17 +284,14 @@ write_env() {
printf '# J-Board panel\n'
printf 'APP_PORT="%s"\n' "$(env_escape "$APP_PORT")"
printf 'SITE_NAME="%s"\n' "$(env_escape "$SITE_NAME")"
printf '\n# PostgreSQL for local tools; Docker Compose overrides host to db\n'
printf 'POSTGRES_PASSWORD="%s"\n' "$(env_escape "$POSTGRES_PASSWORD")"
printf 'DATABASE_URL="postgresql://jboard:%s@localhost:5432/jboard"\n' "$(env_escape "$POSTGRES_PASSWORD")"
printf '\n# SQLite for local tools and Docker\n'
printf 'DATABASE_URL="file:./storage/jboard.db"\n'
printf '\n# NextAuth\n'
printf 'NEXTAUTH_SECRET="%s"\n' "$(env_escape "$NEXTAUTH_SECRET")"
printf 'NEXTAUTH_URL="%s"\n' "$(env_escape "$PUBLIC_URL")"
printf 'SUBSCRIPTION_URL="%s"\n' "$(env_escape "$SUBSCRIPTION_PUBLIC_URL")"
printf '\n# Must be at least 32 bytes, used for AES-256-GCM encryption\n'
printf 'ENCRYPTION_KEY="%s"\n' "$(env_escape "$ENCRYPTION_KEY")"
printf '\n# Redis connection URL for local tools; Docker Compose overrides host to redis\n'
printf 'REDIS_URL="redis://localhost:6379"\n'
printf '\n# Initial admin account, used by npm run db:seed on first install\n'
printf 'ADMIN_EMAIL="%s"\n' "$(env_escape "$ADMIN_EMAIL")"
printf 'ADMIN_PASSWORD="%s"\n' "$(env_escape "$ADMIN_PASSWORD")"
@@ -341,7 +337,6 @@ configure_env() {
ADMIN_EMAIL="$(prompt_value "管理员邮箱" "admin@jboard.local")"
ADMIN_PASSWORD="$(prompt_generated "管理员密码" "$(random_password)" "回车会生成一个安全密码,部署完成后会在结果中显示一次。")"
ADMIN_NAME="$(prompt_value "管理员昵称" "Admin")"
POSTGRES_PASSWORD="$(prompt_generated "PostgreSQL 密码" "$(random_password)")"
NEXTAUTH_SECRET="$(prompt_generated "NEXTAUTH_SECRET" "$(random_hex 32)")"
ENCRYPTION_KEY="$(prompt_generated "ENCRYPTION_KEY" "$(random_hex 32)" "生产使用后不要更换 ENCRYPTION_KEY否则已加密的面板密码、Token、凭据会无法解密。")"

View File

@@ -1,4 +1,3 @@
import { getRedis } from "./redis";
import { randomUUID } from "crypto";
interface RateLimitResult {
@@ -7,12 +6,13 @@ interface RateLimitResult {
reset: number;
}
/**
* Sliding window rate limiter using Redis.
* @param key - Unique identifier (e.g. `ratelimit:payment:${userId}`)
* @param limit - Max requests allowed in the window
* @param windowSeconds - Time window in seconds
*/
const globalForRateLimit = globalThis as unknown as {
rateLimitBuckets?: Map<string, Array<{ score: number; id: string }>>;
};
const buckets = globalForRateLimit.rateLimitBuckets ?? new Map<string, Array<{ score: number; id: string }>>();
globalForRateLimit.rateLimitBuckets = buckets;
export async function rateLimit(
key: string,
limit: number,
@@ -20,33 +20,22 @@ export async function rateLimit(
): Promise<RateLimitResult> {
const now = Date.now();
const windowMs = windowSeconds * 1000;
const cutoff = now - windowMs;
try {
const redis = getRedis();
if (redis.status === "wait") {
await redis.connect();
}
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, 0, now - windowMs);
pipeline.zadd(key, now, `${now}:${randomUUID()}`);
pipeline.zcard(key);
pipeline.expire(key, windowSeconds);
const results = await pipeline.exec();
const count = (results?.[2]?.[1] as number) ?? 0;
return {
success: count <= limit,
remaining: Math.max(0, limit - count),
reset: Math.ceil(windowSeconds - (now % (windowSeconds * 1000)) / 1000),
};
} catch {
// If Redis is unavailable, degrade gracefully instead of blocking user actions.
return {
success: true,
remaining: limit,
reset: windowSeconds,
};
for (const [bucketKey, items] of buckets) {
const active = items.filter((item) => item.score > cutoff);
if (active.length > 0) buckets.set(bucketKey, active);
else buckets.delete(bucketKey);
}
const bucket = buckets.get(key) ?? [];
bucket.push({ score: now, id: `${now}:${randomUUID()}` });
buckets.set(key, bucket);
const count = bucket.length;
return {
success: count <= limit,
remaining: Math.max(0, limit - count),
reset: Math.ceil(windowSeconds - (now % windowMs) / 1000),
};
}

View File

@@ -1,36 +0,0 @@
import Redis from "ioredis";
const globalForRedis = globalThis as unknown as {
redis?: Redis;
redisErrorBound?: boolean;
};
function createRedis() {
const client = new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
lazyConnect: true,
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
});
if (!globalForRedis.redisErrorBound) {
client.on("error", (error) => {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[redis] ${message}`);
});
globalForRedis.redisErrorBound = true;
}
return client;
}
export function getRedis() {
if (!globalForRedis.redis) {
globalForRedis.redis = createRedis();
}
return globalForRedis.redis;
}
if (process.env.NODE_ENV !== "production") {
globalForRedis.redis = globalForRedis.redis ?? createRedis();
}