feat: separate subscription base url

This commit is contained in:
JetSprow
2026-04-29 13:46:10 +10:00
parent 68eac100f2
commit a0c1a28f5a
14 changed files with 80 additions and 26 deletions

View File

@@ -9,7 +9,11 @@ DATABASE_URL="postgresql://jboard:jboard123@localhost:5432/jboard"
# NextAuth # NextAuth
# Use the public domain you will reverse proxy to this panel, for example https://panel.example.com # Use the public domain you will reverse proxy to this panel, for example https://panel.example.com
NEXTAUTH_SECRET="replace-with-a-long-random-secret" NEXTAUTH_SECRET="replace-with-a-long-random-secret"
NEXTAUTH_URL="https://your-domain.com" NEXTAUTH_URL="https://panel.example.com"
# Optional: dedicated subscription domain. Leave empty to reuse NEXTAUTH_URL.
# It should also reverse proxy to this same panel, for example https://sub.example.com
SUBSCRIPTION_URL=""
# Must be at least 32 bytes, used for AES-256-GCM encryption # Must be at least 32 bytes, used for AES-256-GCM encryption
ENCRYPTION_KEY="0123456789abcdef0123456789abcdef" ENCRYPTION_KEY="0123456789abcdef0123456789abcdef"

View File

@@ -100,7 +100,8 @@ J-Board 只保存售卖和展示所需的节点、入站、客户端镜像数据
| --- | --- | --- | | --- | --- | --- |
| `APP_PORT` | 面板监听端口 | 默认 `3000`。反向代理应转发到 `http://127.0.0.1:APP_PORT`。 | | `APP_PORT` | 面板监听端口 | 默认 `3000`。反向代理应转发到 `http://127.0.0.1:APP_PORT`。 |
| `SITE_NAME` | 站点名称 | 初始化系统设置和邮件模板会使用。 | | `SITE_NAME` | 站点名称 | 初始化系统设置和邮件模板会使用。 |
| `NEXTAUTH_URL` | 网访问地址 | 必须填写你准备反代到面板的正式域名,例如 `https://panel.example.com`。不要填容器内地址。 | | `NEXTAUTH_URL` | 网访问地址 | 必须填写你准备反代到面板的正式域名,例如 `https://panel.example.com`。不要填容器内地址。 |
| `SUBSCRIPTION_URL` | 订阅访问地址 | 可选。用于生成客户端订阅链接,例如 `https://sub.example.com`;留空时复用 `NEXTAUTH_URL`。如果使用独立订阅域名,也要反代到同一个面板服务。 |
| `NEXTAUTH_SECRET` | 登录会话密钥 | 生产环境必须使用随机长字符串。 | | `NEXTAUTH_SECRET` | 登录会话密钥 | 生产环境必须使用随机长字符串。 |
| `ENCRYPTION_KEY` | 敏感信息加密密钥 | 至少 32 字节。生产使用后不要更换,否则 3x-ui 密码、探测 Token、流媒体凭据等已加密数据会无法解密。 | | `ENCRYPTION_KEY` | 敏感信息加密密钥 | 至少 32 字节。生产使用后不要更换,否则 3x-ui 密码、探测 Token、流媒体凭据等已加密数据会无法解密。 |
| `DATABASE_URL` | PostgreSQL 连接 | 本地工具使用Docker 部署时 Compose 会覆盖为容器内数据库地址。 | | `DATABASE_URL` | PostgreSQL 连接 | 本地工具使用Docker 部署时 Compose 会覆盖为容器内数据库地址。 |
@@ -157,7 +158,8 @@ curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board/main/scripts/insta
| --- | --- | | --- | --- |
| 安装目录 | 默认 `/opt/jboard`;如果在仓库内运行脚本则默认当前仓库。 | | 安装目录 | 默认 `/opt/jboard`;如果在仓库内运行脚本则默认当前仓库。 |
| 站点名称 | 面板标题、邮件模板和初始化系统设置会使用。 | | 站点名称 | 面板标题、邮件模板和初始化系统设置会使用。 |
| 网访问地址 | 你准备反向代理到本机 `3000` 端口的域名,例如 `https://panel.example.com`。没有域名时可先用 `http://服务器IP:3000` 测试。 | | 网访问地址 | 你准备反向代理到本机 `3000` 端口的面板域名,例如 `https://panel.example.com`。没有域名时可先用 `http://服务器IP:3000` 测试。 |
| 订阅访问地址 | 用于生成 Clash/V2rayN/Shadowrocket 等客户端订阅链接。可与网站访问地址相同,也可填独立订阅域名,例如 `https://sub.example.com`。 |
| 本机监听端口 | 默认 `3000`Nginx/Caddy/宝塔反代目标就是 `http://127.0.0.1:3000`。 | | 本机监听端口 | 默认 `3000`Nginx/Caddy/宝塔反代目标就是 `http://127.0.0.1:3000`。 |
| 管理员邮箱和密码 | 首次初始化会创建该管理员,脚本完成后会再次打印。 | | 管理员邮箱和密码 | 首次初始化会创建该管理员,脚本完成后会再次打印。 |
| PostgreSQL 密码、`NEXTAUTH_SECRET``ENCRYPTION_KEY` | 可手动输入;回车会自动生成安全值。 | | PostgreSQL 密码、`NEXTAUTH_SECRET``ENCRYPTION_KEY` | 可手动输入;回车会自动生成安全值。 |
@@ -172,6 +174,7 @@ APP_DIR=/opt/jboard GH_REPO=JetSprow/J-Board BRANCH=main bash <(curl -fsSL https
```text ```text
访问地址https://panel.example.com 访问地址https://panel.example.com
订阅地址https://sub.example.com
反代目标http://127.0.0.1:3000 反代目标http://127.0.0.1:3000
管理员邮箱admin@example.com 管理员邮箱admin@example.com
管理员密码:自动生成或你输入的密码 管理员密码:自动生成或你输入的密码
@@ -179,14 +182,16 @@ APP_DIR=/opt/jboard GH_REPO=JetSprow/J-Board BRANCH=main bash <(curl -fsSL https
### 反向代理 ### 反向代理
`NEXTAUTH_URL` 和后台“系统设置 -> 站点域名 / URL”都应该填写公网域名也就是你准备给用户访问、并反向代理到 J-Board 的域名。不要填写 `localhost`、容器名或内网地址。 `NEXTAUTH_URL` 和后台“系统设置 -> 站 URL”都应该填写面板公网域名,也就是你准备给用户访问、并反向代理到 J-Board 的域名。不要填写 `localhost`、容器名或内网地址。
`SUBSCRIPTION_URL` 和后台“系统设置 -> 订阅 URL”只用于生成客户端订阅链接。它可以和网站 URL 相同;如果你想单独做 Cloudflare/WAF/访问风控,建议使用 `https://sub.example.com` 这类独立域名,并把它也反向代理到同一个 J-Board 服务。独立订阅域名只需要承载 `/api/subscription/*`,后续可以在反代或 WAF 层对其他路径返回 404。
Nginx 示例: Nginx 示例:
```nginx ```nginx
server { server {
listen 80; listen 80;
server_name panel.example.com; server_name panel.example.com sub.example.com;
location / { location / {
proxy_pass http://127.0.0.1:3000; proxy_pass http://127.0.0.1:3000;
@@ -201,7 +206,9 @@ server {
} }
``` ```
正式上线建议再用 Certbot、宝塔、1Panel、Caddy 或 CDN 申请 HTTPS 证书,然后把 `NEXTAUTH_URL` 改为 `https://panel.example.com` 正式上线建议再用 Certbot、宝塔、1Panel、Caddy 或 CDN 申请 HTTPS 证书,然后把 `NEXTAUTH_URL` 改为 `https://panel.example.com`如果单独使用订阅域名,把 `SUBSCRIPTION_URL` 或后台订阅 URL 改为 `https://sub.example.com`
订阅域名套 Cloudflare 时,源站应只允许 Cloudflare 回源或通过 Cloudflare Tunnel 暴露服务,并正确传递 `CF-Connecting-IP` / `X-Forwarded-For`。否则后续订阅访问风控中的真实 IP 可能被直连源站请求伪造。
### 手动 Docker 部署 ### 手动 Docker 部署
@@ -209,7 +216,7 @@ server {
```bash ```bash
cp .env.example .env cp .env.example .env
# 编辑 .env尤其是 NEXTAUTH_URL、NEXTAUTH_SECRET、ENCRYPTION_KEY、POSTGRES_PASSWORD、管理员账号 # 编辑 .env尤其是 NEXTAUTH_URL、SUBSCRIPTION_URL、NEXTAUTH_SECRET、ENCRYPTION_KEY、POSTGRES_PASSWORD、管理员账号
docker compose build init app docker compose build init app
docker compose --profile setup run --rm init docker compose --profile setup run --rm init
docker compose up -d app docker compose up -d app
@@ -236,11 +243,12 @@ docker compose up -d app
- 查看日志:`docker compose logs -f app` - 查看日志:`docker compose logs -f app`
- 页面仍是旧版本:确认已执行 `docker compose build init app``docker compose up -d app` - 页面仍是旧版本:确认已执行 `docker compose build init app``docker compose up -d app`
- Schema 没有生效:单独运行 `docker compose --profile setup run --rm init sh -lc 'npm run db:push'` - Schema 没有生效:单独运行 `docker compose --profile setup run --rm init sh -lc 'npm run db:push'`
- 登录回调、邮件链接或支付回跳出现 `localhost`:检查 `.env` 里的 `NEXTAUTH_URL` 和后台系统设置里的站点域名 - 登录回调、邮件链接或支付回跳出现 `localhost`:检查 `.env` 里的 `NEXTAUTH_URL` 和后台系统设置里的网站 URL
- 订阅链接仍然使用主站域名:检查 `.env` 里的 `SUBSCRIPTION_URL` 或后台系统设置里的订阅 URL后台配置优先于环境变量。
### 部署后检查清单 ### 部署后检查清单
1. 登录 `/admin`,进入“系统设置”,确认站点域名就是你的反代域名。 1. 登录 `/admin`,进入“系统设置”,确认网站 URL 是面板反代域名,订阅 URL 是你准备给客户端拉取订阅的域名。
2. 配置 SMTP 邮件服务并点击“测试”,再按需要开启注册邮箱验证。 2. 配置 SMTP 邮件服务并点击“测试”,再按需要开启注册邮箱验证。
3. 进入“支付配置”,填写并启用至少一种支付方式。 3. 进入“支付配置”,填写并启用至少一种支付方式。
4. 添加 3x-ui 节点,测试连接并同步入站。 4. 添加 3x-ui 节点,测试连接并同步入站。

View File

@@ -654,6 +654,7 @@ model AppConfig {
id String @id @default("default") id String @id @default("default")
siteName String @default("J-Board") siteName String @default("J-Board")
siteUrl String? siteUrl String?
subscriptionUrl String?
allowRegistration Boolean @default(true) allowRegistration Boolean @default(true)
emailVerificationRequired Boolean @default(false) emailVerificationRequired Boolean @default(false)
requireInviteCode Boolean @default(false) requireInviteCode Boolean @default(false)

View File

@@ -17,6 +17,7 @@ async function main() {
const adminName = envValue("ADMIN_NAME", "Admin"); const adminName = envValue("ADMIN_NAME", "Admin");
const siteName = envValue("SITE_NAME", "J-Board"); const siteName = envValue("SITE_NAME", "J-Board");
const siteUrl = process.env.NEXTAUTH_URL?.trim() || null; const siteUrl = process.env.NEXTAUTH_URL?.trim() || null;
const subscriptionUrl = process.env.SUBSCRIPTION_URL?.trim() || null;
const hashedPassword = await bcrypt.hash(adminPassword, 12); const hashedPassword = await bcrypt.hash(adminPassword, 12);
await prisma.appConfig.upsert({ await prisma.appConfig.upsert({
@@ -24,11 +25,13 @@ async function main() {
update: { update: {
siteName, siteName,
siteUrl, siteUrl,
subscriptionUrl,
}, },
create: { create: {
id: "default", id: "default",
siteName, siteName,
siteUrl, siteUrl,
subscriptionUrl,
}, },
}); });

View File

@@ -9,6 +9,7 @@ SKIP_DOCKER_INSTALL="${SKIP_DOCKER_INSTALL:-0}"
APP_PORT="" APP_PORT=""
PUBLIC_URL="" PUBLIC_URL=""
SUBSCRIPTION_PUBLIC_URL=""
SITE_NAME="" SITE_NAME=""
ADMIN_EMAIL="" ADMIN_EMAIL=""
ADMIN_PASSWORD="" ADMIN_PASSWORD=""
@@ -264,6 +265,7 @@ load_existing_env() {
fi fi
APP_PORT="${APP_PORT:-3000}" APP_PORT="${APP_PORT:-3000}"
PUBLIC_URL="${NEXTAUTH_URL:-}" PUBLIC_URL="${NEXTAUTH_URL:-}"
SUBSCRIPTION_PUBLIC_URL="${SUBSCRIPTION_URL:-}"
SITE_NAME="${SITE_NAME:-J-Board}" SITE_NAME="${SITE_NAME:-J-Board}"
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@jboard.local}" ADMIN_EMAIL="${ADMIN_EMAIL:-admin@jboard.local}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
@@ -289,6 +291,7 @@ write_env() {
printf '\n# NextAuth\n' printf '\n# NextAuth\n'
printf 'NEXTAUTH_SECRET="%s"\n' "$(env_escape "$NEXTAUTH_SECRET")" printf 'NEXTAUTH_SECRET="%s"\n' "$(env_escape "$NEXTAUTH_SECRET")"
printf 'NEXTAUTH_URL="%s"\n' "$(env_escape "$PUBLIC_URL")" 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 '\n# Must be at least 32 bytes, used for AES-256-GCM encryption\n'
printf 'ENCRYPTION_KEY="%s"\n' "$(env_escape "$ENCRYPTION_KEY")" 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 '\n# Redis connection URL for local tools; Docker Compose overrides host to redis\n'
@@ -330,8 +333,10 @@ configure_env() {
default_url="http://${ip}:3000" default_url="http://${ip}:3000"
SITE_NAME="$(prompt_value "站点名称" "J-Board")" SITE_NAME="$(prompt_value "站点名称" "J-Board")"
PUBLIC_URL="$(prompt_value "网访问地址" "$default_url" "这里请填写你准备反向代理到本机 3000 端口的正式域名,例如 https://panel.example.com。没有域名时可先回车用 IP:3000 测试。")" PUBLIC_URL="$(prompt_value "网访问地址" "$default_url" "这里请填写你准备反向代理到本机 3000 端口的面板域名,例如 https://panel.example.com。没有域名时可先回车用 IP:3000 测试。")"
PUBLIC_URL="$(normalize_url "$PUBLIC_URL")" PUBLIC_URL="$(normalize_url "$PUBLIC_URL")"
SUBSCRIPTION_PUBLIC_URL="$(prompt_value "订阅访问地址" "$PUBLIC_URL" "用于生成客户端订阅链接。可以和网站地址相同,也可以填单独反代到本面板的订阅域名,例如 https://sub.example.com。")"
SUBSCRIPTION_PUBLIC_URL="$(normalize_url "$SUBSCRIPTION_PUBLIC_URL")"
APP_PORT="$(prompt_value "本机监听端口" "3000" "反向代理目标会是 http://127.0.0.1:端口,默认 3000。")" APP_PORT="$(prompt_value "本机监听端口" "3000" "反向代理目标会是 http://127.0.0.1:端口,默认 3000。")"
ADMIN_EMAIL="$(prompt_value "管理员邮箱" "admin@jboard.local")" ADMIN_EMAIL="$(prompt_value "管理员邮箱" "admin@jboard.local")"
ADMIN_PASSWORD="$(prompt_generated "管理员密码" "$(random_password)" "回车会生成一个安全密码,部署完成后会在结果中显示一次。")" ADMIN_PASSWORD="$(prompt_generated "管理员密码" "$(random_password)" "回车会生成一个安全密码,部署完成后会在结果中显示一次。")"
@@ -389,13 +394,14 @@ print_summary() {
printf '%s\n' "J-Board 部署完成" printf '%s\n' "J-Board 部署完成"
printf '%s\n' "============================================================" printf '%s\n' "============================================================"
printf '访问地址:%s\n' "${PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-3000}}" printf '访问地址:%s\n' "${PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-3000}}"
printf '订阅地址:%s\n' "${SUBSCRIPTION_PUBLIC_URL:-${PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-3000}}}"
printf '反代目标:%s\n' "$proxy_target" printf '反代目标:%s\n' "$proxy_target"
printf '管理员邮箱:%s\n' "${ADMIN_EMAIL:-admin@jboard.local}" printf '管理员邮箱:%s\n' "${ADMIN_EMAIL:-admin@jboard.local}"
printf '管理员密码:%s\n' "$shown_password" printf '管理员密码:%s\n' "$shown_password"
echo echo
printf '%s\n' "反向代理提示" printf '%s\n' "反向代理提示"
printf ' 将你的域名解析到这台服务器,并把 Web 反向代理到 %s。\n' "$proxy_target" printf ' 将你的面板域名解析到这台服务器,并把 Web 反向代理到 %s。\n' "$proxy_target"
printf ' NEXTAUTH_URL 与后台系统设置中的站点域名都应使用这个公网域名。\n' printf ' 如果使用独立订阅域名,也把它反向代理到同一个目标,并在后台系统设置中填写订阅 URL。\n'
echo echo
printf '%s\n' "可以展示给用户的入口" printf '%s\n' "可以展示给用户的入口"
printf ' 首页 / 登录:%s/login\n' "${PUBLIC_URL%/}" printf ' 首页 / 登录:%s/login\n' "${PUBLIC_URL%/}"
@@ -404,7 +410,7 @@ print_summary() {
printf ' 用户中心:%s/dashboard\n' "${PUBLIC_URL%/}" printf ' 用户中心:%s/dashboard\n' "${PUBLIC_URL%/}"
echo echo
printf '%s\n' "上线前建议完成" printf '%s\n' "上线前建议完成"
printf ' 1. 后台 /admin/settings确认站点域名、SMTP 邮件服务、注册策略。\n' printf ' 1. 后台 /admin/settings确认网站 URL、订阅 URL、SMTP 邮件服务、注册策略。\n'
printf ' 2. 后台 /admin/payments配置并启用支付方式。\n' printf ' 2. 后台 /admin/payments配置并启用支付方式。\n'
printf ' 3. 后台 /admin/nodes添加 3x-ui 节点并同步入站。\n' printf ' 3. 后台 /admin/nodes添加 3x-ui 节点并同步入站。\n'
printf ' 4. 后台 /admin/plans创建套餐并绑定入站或流媒体服务。\n' printf ' 4. 后台 /admin/plans创建套餐并绑定入站或流媒体服务。\n'

View File

@@ -14,6 +14,7 @@ import { sendSmtpTestEmail } from "@/services/email";
const settingsSchema = z.object({ const settingsSchema = z.object({
siteName: z.string().trim().min(1, "站点名称不能为空"), siteName: z.string().trim().min(1, "站点名称不能为空"),
siteUrl: z.string().trim().optional(), siteUrl: z.string().trim().optional(),
subscriptionUrl: z.string().trim().optional(),
supportContact: z.string().trim().optional(), supportContact: z.string().trim().optional(),
maintenanceNotice: z.string().trim().optional(), maintenanceNotice: z.string().trim().optional(),
siteNotice: z.string().trim().optional(), siteNotice: z.string().trim().optional(),
@@ -88,6 +89,7 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
const next = { const next = {
siteName: parsed.siteName, siteName: parsed.siteName,
siteUrl: normalizeSiteUrl(parsed.siteUrl) || null, siteUrl: normalizeSiteUrl(parsed.siteUrl) || null,
subscriptionUrl: normalizeSiteUrl(parsed.subscriptionUrl) || null,
supportContact: parsed.supportContact || null, supportContact: parsed.supportContact || null,
maintenanceNotice: parsed.maintenanceNotice || null, maintenanceNotice: parsed.maintenanceNotice || null,
siteNotice: parsed.siteNotice || null, siteNotice: parsed.siteNotice || null,

View File

@@ -166,7 +166,7 @@ export function NodeActions({ node, siteUrl }: { node: NodeActionValue; siteUrl:
{!siteUrl && ( {!siteUrl && (
<p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-700 dark:text-amber-200"> <p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-700 dark:text-amber-200">
URL
</p> </p>
)} )}
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">

View File

@@ -25,6 +25,7 @@ export default async function AdminSettingsPage() {
config={{ config={{
siteName: config.siteName, siteName: config.siteName,
siteUrl: config.siteUrl, siteUrl: config.siteUrl,
subscriptionUrl: config.subscriptionUrl,
supportContact: config.supportContact, supportContact: config.supportContact,
maintenanceNotice: config.maintenanceNotice, maintenanceNotice: config.maintenanceNotice,
siteNotice: config.siteNotice, siteNotice: config.siteNotice,

View File

@@ -14,6 +14,7 @@ import { getErrorMessage } from "@/lib/errors";
interface AppConfig { interface AppConfig {
siteName: string; siteName: string;
siteUrl: string | null; siteUrl: string | null;
subscriptionUrl: string | null;
supportContact: string | null; supportContact: string | null;
maintenanceNotice: string | null; maintenanceNotice: string | null;
siteNotice: string | null; siteNotice: string | null;
@@ -130,9 +131,14 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<Input id="siteName" name="siteName" defaultValue={config.siteName} required /> <Input id="siteName" name="siteName" defaultValue={config.siteName} required />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="siteUrl"> / URL</Label> <Label htmlFor="siteUrl"> URL</Label>
<Input id="siteUrl" name="siteUrl" defaultValue={config.siteUrl ?? ""} placeholder="https://example.com" /> <Input id="siteUrl" name="siteUrl" defaultValue={config.siteUrl ?? ""} placeholder="https://panel.example.com" />
<p className="text-xs leading-5 text-muted-foreground"> Agent </p> <p className="text-xs leading-5 text-muted-foreground"> Agent </p>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="subscriptionUrl"> URL</Label>
<Input id="subscriptionUrl" name="subscriptionUrl" defaultValue={config.subscriptionUrl ?? ""} placeholder="https://sub.example.com" />
<p className="text-xs leading-5 text-muted-foreground"> URL 使 sub 便 Cloudflare/WAF 访</p>
</div> </div>
<div className="space-y-2 md:col-span-2"> <div className="space-y-2 md:col-span-2">
<Label htmlFor="supportContact"></Label> <Label htmlFor="supportContact"></Label>

View File

@@ -7,7 +7,7 @@ import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-s
import { SubscriptionDetailCards } from "@/components/subscriptions/subscription-detail-cards"; import { SubscriptionDetailCards } from "@/components/subscriptions/subscription-detail-cards";
import { SubscriptionTimelineSection } from "@/components/subscriptions/subscription-timeline-section"; import { SubscriptionTimelineSection } from "@/components/subscriptions/subscription-timeline-section";
import { TrafficLogs } from "./_components/traffic-logs"; import { TrafficLogs } from "./_components/traffic-logs";
import { getSiteBaseUrl } from "@/services/site-url"; import { getSubscriptionBaseUrl } from "@/services/site-url";
import { ProxySubscriptionDetails } from "../_components/proxy-subscription-details"; import { ProxySubscriptionDetails } from "../_components/proxy-subscription-details";
import { StreamingCredentialCard } from "../streaming-credential-card"; import { StreamingCredentialCard } from "../streaming-credential-card";
import { getUserSubscriptionDetail } from "./subscription-detail-data"; import { getUserSubscriptionDetail } from "./subscription-detail-data";
@@ -30,7 +30,7 @@ export default async function UserSubscriptionDetailPage({
subscriptionId: id, subscriptionId: id,
userId: session!.user.id, userId: session!.user.id,
}), }),
getSiteBaseUrl({ headers: requestHeaders }), getSubscriptionBaseUrl({ headers: requestHeaders }),
]); ]);
if (!data) { if (!data) {

View File

@@ -1,6 +1,6 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getSiteBaseUrl } from "@/services/site-url"; import { getSubscriptionBaseUrl as resolveSubscriptionBaseUrl } from "@/services/site-url";
import { import {
getPlanTrafficPoolState, getPlanTrafficPoolState,
type PlanTrafficPoolState, type PlanTrafficPoolState,
@@ -22,7 +22,7 @@ export async function getUserSubscriptions(userId: string): Promise<Subscription
export async function getSubscriptionBaseUrl() { export async function getSubscriptionBaseUrl() {
const requestHeaders = await headers(); const requestHeaders = await headers();
return getSiteBaseUrl({ headers: requestHeaders }); return resolveSubscriptionBaseUrl({ headers: requestHeaders });
} }
export async function getTrafficPoolMap(subscriptions: SubscriptionRecord[]) { export async function getTrafficPoolMap(subscriptions: SubscriptionRecord[]) {

View File

@@ -73,7 +73,7 @@ export async function POST(req: Request) {
const baseUrl = await getSiteBaseUrl({ headers: req.headers, requestUrl: req.url }); const baseUrl = await getSiteBaseUrl({ headers: req.headers, requestUrl: req.url });
if (!baseUrl) { if (!baseUrl) {
return jsonError("请先在后台系统设置里配置站点域名", { status: 400 }); return jsonError("请先在后台系统设置里配置网站 URL", { status: 400 });
} }
const result = await adapter.createPayment({ const result = await adapter.createPayment({
tradeNo, tradeNo,

View File

@@ -132,7 +132,7 @@ async function buildActionUrl(pathname: string, token: string, options: { header
allowRequestFallback: true, allowRequestFallback: true,
}); });
if (!baseUrl) { if (!baseUrl) {
throw new Error("请先在系统设置中填写站点域名"); throw new Error("请先在系统设置中填写网站 URL");
} }
const url = new URL(pathname, baseUrl); const url = new URL(pathname, baseUrl);

View File

@@ -18,14 +18,14 @@ export function normalizeSiteUrl(raw: string | null | undefined): string | null
try { try {
url = new URL(withProtocol); url = new URL(withProtocol);
} catch { } catch {
throw new Error("站点域名格式不正确,请填写 https://example.com"); throw new Error("URL 格式不正确,请填写 https://example.com");
} }
if (url.protocol !== "http:" && url.protocol !== "https:") { if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("站点域名仅支持 http:// 或 https://"); throw new Error("URL 仅支持 http:// 或 https://");
} }
if (!url.hostname) { if (!url.hostname) {
throw new Error("站点域名不能为空"); throw new Error("URL 主机不能为空");
} }
url.search = ""; url.search = "";
@@ -79,6 +79,14 @@ export async function getConfiguredSiteUrl(db: DbClient = prisma): Promise<strin
return safeNormalizeSiteUrl(config.siteUrl) ?? safeNormalizeSiteUrl(process.env.NEXTAUTH_URL); return safeNormalizeSiteUrl(config.siteUrl) ?? safeNormalizeSiteUrl(process.env.NEXTAUTH_URL);
} }
export async function getConfiguredSubscriptionUrl(db: DbClient = prisma): Promise<string | null> {
const config = await getAppConfig(db);
return safeNormalizeSiteUrl(config.subscriptionUrl)
?? safeNormalizeSiteUrl(process.env.SUBSCRIPTION_URL)
?? safeNormalizeSiteUrl(config.siteUrl)
?? safeNormalizeSiteUrl(process.env.NEXTAUTH_URL);
}
export async function getSiteBaseUrl(options: { export async function getSiteBaseUrl(options: {
headers?: Headers; headers?: Headers;
requestUrl?: string; requestUrl?: string;
@@ -93,3 +101,18 @@ export async function getSiteBaseUrl(options: {
options.headers ? getForwardedSiteUrl(options.headers) : null options.headers ? getForwardedSiteUrl(options.headers) : null
) ?? getRequestOriginUrl(options.requestUrl) ?? ""; ) ?? getRequestOriginUrl(options.requestUrl) ?? "";
} }
export async function getSubscriptionBaseUrl(options: {
headers?: Headers;
requestUrl?: string;
db?: DbClient;
allowRequestFallback?: boolean;
} = {}): Promise<string> {
const configured = await getConfiguredSubscriptionUrl(options.db ?? prisma);
if (configured) return configured;
if (!options.allowRequestFallback) return "";
return (
options.headers ? getForwardedSiteUrl(options.headers) : null
) ?? getRequestOriginUrl(options.requestUrl) ?? "";
}