碎碎念
芜湖!大家好!我又回来啦!
上一篇还停留在十个月前,中间域名换了、服务器买了一个又一个、班也上了,博客倒是纯纯长草,连群友都快把我站点忘干净了(笑)。
不过回来的这两天我倒也不是纯玩——先给自己重新修复了一个导航页

然后又在给自己的博客平台 Anheyu(安和鱼)搓主题。事情是这样的:Anheyu 之前的主题系统只支持静态 HTML,说白了就是 Go 直出一个 index.html,前端拿 JS 拉 API 渲染,类 SPA。这个模式对 Hexo 风格移植很友好,我之前的主题也是这种,但是由于鱼鱼更新太快了,导致我维护跟不上,所以最后又切回了默认主题,这种整站 Next.js 的主题的SEO 基本上等于没有,浏览器爬虫看不见文章内容,懂的都懂。
后来鱼鱼把 SSR 主题的能力做了进来:Go 后端直接拉起一个 node server.js 子进程,真·服务端渲染(我写主题的时候是不知道的)。当我写完这个主题之后,开始构建,然后发现构建的文件和鱼鱼在文档中写的不一样,拿AI一读,发现原来社区版的APP已经支持容器内部起SSR主题了。
然后,就是一个坑接着一个坑,从端口冲突一路踩到官方后台白屏,硬生生把一次"装个主题"折腾成了"读一遍后端源码"。
这篇文章就把这整个链路从头到尾捋一遍:怎么开发、怎么打包、怎么安装、路由怎么分工,以及我踩到的四个真实的坑。踩坑部分占了大头,毕竟这才是这篇文章真正的主题嘻嘻。
丑话说在前面:本文所有结论都来自我自己的服务器 PRO 实例和一遍遍实测,不同版本、不同部署方式可能会有一些差异;另外因为有些源码细节是逐行翻出来确认的(社区版代码,和PRO的功能有部分不同),如果上游后续改动了,以最新代码为准,也欢迎踩到不一样的坑回来评论区来找我讨论。
我折腾那么久是因为我想使用PRO的默认的后台管理界面来管理我的内容,因为这个鱼鱼一直在更新,而且没有开放源码,所以我就想直接使用这个,然后前台用我的主题,就是因为这个才导致出现了很多的问题。
那么,正式开始吧~
先分清楚:社区版和 PRO 版
工欲善其事,必先把自己的站点是什么版本搞清楚。Anheyu 是 Go 后端 + Next.js 前台的内容管理平台,官方维护两个版本:
社区版(anheyu-app) | PRO 版(anheyu-pro) | |
|---|---|---|
开源协议 | GPL-3.0,GitHub 公开 | 商业闭源 |
后台管理 | Next.js 前端内的 | 独立的付费版后台,功能更全 |
内容形态 | 文章/分类/标签/相册/文档 | 额外支持付费文章、密码文章、登录保护内容、即刻说说、多用户共创 |
支付与登录 | 基础功能 | 微信/支付宝/易支付/虎皮椒;QQ/微信/GitHub/OIDC 登录 |
AI 能力 | 无 | AI 写作、AI 播客等 |
主题商城 | 有(公开 API) | 有,且 PRO 主题( |
好消息是,两者共享同一套 SSR 主题基础设施——PRO 直接导入了社区版的 pkg/ssr、pkg/middleware 包,源码注释原话是"此包可被外部项目(如 anheyu-pro)导入使用"。所以下面讲的打包、安装、路由机制,对两个版本都成立。

从这个图片就可以知道,自己上传的SSR主题是读取不到各种信息的(鱼鱼还没优化好这个,不影响正常使用)
开发阶段:先把它跑起来
仓库结构,第一眼就容易懵
主仓库的前台是一个 Git 子模块,克隆时必须递归拉取,不然 frontend/ 是个空目录,第一次见的人都以为仓库坏了:
BASH
git clone --recurse-submodules https://github.com/anzhiyu-c/anheyu-app.git
# 已经 clone 过但 frontend 为空的补救:
git submodule update --init --recursive
主题开发一般不直接改子模块,而是基于独立的前台仓库(如 anheyu-app-frontend)开发,最终产物作为主题包分发。我自己就是这条路。
本地环境
BASH
npm install # 官方文档推荐 pnpm,仓库用 npm 也可以
# 然后在根目录下(主题的根目录)创建一个.env.local
# .env.local 里配置后端地址
# BACKEND_URL=https://你的Anheyu-App地址
npm run dev # 默认 3000 端口

常见修改点速查
方便后来人,放一张我自己开发时反复对照的表:
想改什么 | 去哪儿改 |
|---|---|
主题色 / 暗色模式 | 全局 CSS 变量( |
页面布局 |
|
组件 |
|
接口请求 |
|
主题元信息 |
|
开发部分讲完了,接下来才是重头戏——你要把这个 Next.js 项目变成一个"能被 Anheyu 装上"的包,就得先理解一个非常重要的前提:
静态主题和 SSR 主题,是两条完全不同的路。
静态 HTML 主题 vs SSR 主题
官方文档里那份"theme-name.zip 结构规范"(theme.json + index.html + static/)只适用于静态主题。我第一次打包时就照着那份规范去过,结果自然是被后端拒之门外。SSR 主题走的是另一套后端模块,两者差异非常大,千万别混:
静态 HTML 主题 | SSR 主题 | |
|---|---|---|
包格式 | zip | tar.gz |
入口 |
|
|
运行方式 | Go 直出静态文件 | Go 拉起 |
渲染 | 无 SSR,SEO 弱 | 真 SSR,SEO 完整 |
安装入口 | 后台"上传主题"(multipart 文件上传) |
|
文件校验 | 严格白名单(禁 .sh/.py/.exe/无扩展名文件等) | 无白名单,但有条目数/体积限额 |
体积限制 | zip ≤ 50MB | 压缩包 ≤ 256MB、解压 ≤ 512MB、条目 ≤ 10000 |
theme.json 关键字段 | 无 renderMode |
|
适合场景 | 轻量模板、Hexo 风格移植 | Next.js 完整主题(本项目这种) |
打包:把 Next.js 塞进 tar.gz
后端对包的真实要求
这些要求文档里没写全,我是逐行对照 pkg/ssr/manager.go 的源码翻出来的,有三条容易踩的硬规则:
只认 tar.gz:
extractTarGz用 gzip+tar 解析,zip 直接失败;必须包一层顶层目录:解压时无条件去掉每条路径的第一级(为了剥掉
theme-xxx/包装目录),扁平打包会把node_modules拆散;server.js必须在解压后的根目录:启动逻辑就是检查根目录的server.js然后node server.js。
好消息是,npm run build 的 .next/standalone 恰好以 server.js 为根,天然符合第 3 条;坏消息是它不包含 .next/static 和 public/,需要手动补——第一次打包漏掉这两个,装上去直接资源 404 全家桶。
一键打包脚本
想清楚规则之后,这事就简单了。给 package.json 加一条脚本:
JSON
"build:theme": "next build && node scripts/package-theme.mjs"
scripts/package-theme.mjs 的内容如下:
JS
/*
* SSR 主题打包脚本(npm run build 之后执行)
* 产出 dist/<theme-name>-<version>.tar.gz,供 /api/admin/ssr-theme/install 的 downloadUrl 使用
*
* 包结构约定(与后端 pkg/ssr/manager.go 对齐):
* - 格式必须为 tar.gz(extractTarGz 只接受 gzip+tar)
* - 必须包一层顶层目录(解压时无条件去掉每条路径的第一级,扁平打包会拆散 node_modules)
* - 顶层目录内必须直接可见 server.js(Start 时执行 node server.js)
* - .next/static 与 public 不在 standalone 内,需手动补入
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const root = process.cwd();
const nextDir = path.join(root, ".next");
const standaloneDir = path.join(nextDir, "standalone");
const staticDir = path.join(nextDir, "static");
const publicDir = path.join(root, "public");
const distDir = path.join(root, "dist");
const fail = (msg) => {
console.error(`[package-theme] ${msg}`);
process.exit(1);
};
// 1. 读取主题元信息(name 必须形如 theme-xxx,后端 validateThemeName 拒绝 / \ : 与 ..)
const themeMeta = JSON.parse(
fs.readFileSync(path.join(publicDir, "theme.json"), "utf8")
);
const { name: themeName, version } = themeMeta;
if (!/^theme-[a-z0-9-]+$/.test(themeName)) {
fail(`theme.json 的 name "${themeName}" 不符合 ^theme-[a-z0-9-]+$,将被后端拒绝`);
}
// 2. 校验构建产物存在(说明已执行过 npm run build 且 output: standalone 生效)
if (!fs.existsSync(path.join(standaloneDir, "server.js"))) {
fail("未找到 .next/standalone/server.js,请先执行 npm run build");
}
if (!fs.existsSync(staticDir)) {
fail("未找到 .next/static,请先执行 npm run build");
}
// 3. 组装暂存目录:dist/<theme-name>/ = standalone + .next/static + public
const themeDir = path.join(distDir, themeName);
fs.rmSync(themeDir, { recursive: true, force: true });
fs.mkdirSync(themeDir, { recursive: true });
console.log("[package-theme] 复制 standalone 主体(server.js + node_modules)...");
fs.cpSync(standaloneDir, themeDir, { recursive: true });
console.log("[package-theme] 补入 .next/static ...");
fs.cpSync(staticDir, path.join(themeDir, ".next", "static"), { recursive: true });
console.log("[package-theme] 补入 public/(含 theme.json,运行时可访问 /theme.json)...");
fs.cpSync(publicDir, path.join(themeDir, "public"), { recursive: true });
// 4. 打成 tar.gz(保留顶层目录名;Windows 10+ 自带 bsdtar)
const outFile = path.join(distDir, `${themeName}-${version}.tar.gz`);
fs.rmSync(outFile, { force: true });
try {
execFileSync("tar", ["-czf", outFile, "-C", distDir, themeName], {
stdio: "inherit",
});
} catch {
fail("调用系统 tar 失败,请确认 Windows 10+ 自带的 tar.exe 可用(where tar)");
}
// 5. 输出体积与条目数(后端限制:下载 ≤256MB、条目 ≤10000、单文件 ≤100MB)
const sizeMB = (fs.statSync(outFile).size / 1024 / 1024).toFixed(1);
const entries = execFileSync("tar", ["-tzf", outFile])
.toString()
.split(/\r?\n/)
.filter(Boolean).length;
console.log(`\n[package-theme] 打包完成:${outFile}`);
console.log(`[package-theme] 压缩包大小:${sizeMB} MB,归档条目数:${entries}`);
if (entries > 10000) {
console.warn(
`[package-theme] ⚠️ 条目数超过 10000,后端 extractTarGz 会以「主题归档超过解压限制」拒绝安装,需精简 node_modules 或与上游确认限额`
);
}
if (Number(sizeMB) > 256) {
console.warn("[package-theme] ⚠️ 超过后端 256MB 下载大小限制");
}
跑一次的结果参考:theme-byer-blog-1.0.4.tar.gz,16.5 MB、3219 条目。看着挺小,其实 node_modules 里 standalone 已经被 Next 裁剪过一轮了,不然这个尺寸要翻好几倍。
安装:后台上传不了,得走 URL 直装
包打好了,下一步就是把它装进站点。然后我撞上了本次第一个的问题——
为什么后台"上传主题"装不了 SSR 包
后台的上传弹窗走 /api/theme/upload,它是静态主题专用通道:
前端弹窗
accept=".zip",选了 tar.gz 会静默失败(连报错都没有,当时我还以为是自己网络问题,刷新了三遍);后端
UploadTheme硬校验 zip 格式,ValidateThemePackage还要求index.html+ 文件白名单——就算前端放行了,node_modules里大量无扩展名文件(LICENSE、bin)也会全被拒。
SSR 主题唯一的官方安装通道是这个:
JS
POST /api/admin/ssr-theme/install
{ "themeName": "theme-byer-blog", "downloadUrl": "https://.../theme-byer-blog-1.0.4.tar.gz" }
downloadUrl 的隐藏门槛
看到这行我第一反应是:巧了,我本地就有 HTTP 服务。然后就被教做人了——后端下载器 util.ValidatePublicHTTPURL 的限制比想象中严:
只允许 HTTPS(
NewPublicHTTPClient(timeout, true)的 httpsOnly=true);端口只能是默认 443;
拒绝 localhost、*.local、回环地址、所有 RFC1918 私网 IP,连 DNS 重绑定都用 dial-pinning 防了;
重定向目标逐级复检。
结论:本地起个 python -m http.server 是喂不进去的。这是刻意的 SSRF 防护,站在平台角度完全合理(毕竟后端是拿自己的身份去请求这个 URL 的),但站在本地开发角度就是纯纯的折磨了。实操上只能把 tar.gz 传到任意公网 HTTPS 存储——对象存储、图床、或者干脆自己站点的文件管理模块,拿直链来装。
顺带一提,这里有个很妙的循环:我的站点本身就是 Anheyu,所以我是把包传到自己站点的文件管理里,再让同一个后端的安装接口来下载自己站点上的文件。我自己让我自己下载(笑)。
那就自己加个安装入口呗
既然官方后台没有面向"自用开发者"的安装 UI,那就自己写一个,反正这个后台我不会使用,刚好在本地修改完主题,直接在本地后台上传。在主题管理页加一个「SSR 安装」按钮 + 弹窗(InstallSSRThemeDialog.tsx),三个字段:
下载地址(必填,前端先做一遍和后端一致的预校验:HTTPS/端口/私网 IP 拦截,避免提交后才报错);
主题名称(留空时自动从文件名推导:去掉
.tar.gz扩展名和-1.0.x版本后缀,需匹配^theme-[a-z0-9-]+$);版本号(选填,仅用于后台记录)。
在src/types/theme-mall.ts末尾追加:
TYPESCRIPT
/** 部署类型 */
export type DeployType = "standard" | "ssr";
/** SSR 主题状态 */
export type SSRThemeStatus = "not_installed" | "installed" | "running" | "error";
/** SSR 主题信息 */
export interface SSRThemeInfo {
name: string;
version: string;
status: SSRThemeStatus;
port?: number;
installedAt?: string;
startedAt?: string;
is_current?: boolean;
}
/** SSR 主题安装请求(字段是驼峰,注意和静态主题接口的下划线风格不同) */
export interface SSRThemeInstallRequest {
themeName: string;
downloadUrl: string;
/** 版本号,用于后台记录,可缺省 */
version?: string;
}
/** SSR 主题启动请求 */
export interface SSRThemeStartRequest {
port?: number;
}在src/lib/api/theme-mall.ts的themeMallApi对象末尾(主题配置接口之后)追加:
TYPESCRIPT
// ===== SSR 主题管理接口 =====
/** 安装 SSR 主题(管理员) */
installSSRTheme: async (data: SSRThemeInstallRequest): Promise<void> => {
await apiClient.post("/api/admin/ssr-theme/install", data);
},
/** 列出已安装 SSR 主题(管理员) */
getInstalledSSRThemes: async (): Promise<SSRThemeInfo[]> => {
const res = await apiClient.get<SSRThemeInfo[]>("/api/admin/ssr-theme/list");
return res.data;
},
/** 卸载 SSR 主题(管理员) */
uninstallSSRTheme: async (name: string): Promise<void> => {
await apiClient.delete(`/api/admin/ssr-theme/${name}`);
},
/** 启动 SSR 主题(管理员) */
startSSRTheme: async (name: string, data?: SSRThemeStartRequest): Promise<{ port: number }> => {
const res = await apiClient.post<{ port: number }>(`/api/admin/ssr-theme/${name}/start`, data ?? {});
return res.data;
},
/** 停止 SSR 主题(管理员) */
stopSSRTheme: async (name: string): Promise<void> => {
await apiClient.post(`/api/admin/ssr-theme/${name}/stop`);
},别忘了在文件顶部的import type { ... } from "@/types/theme-mall"里补上SSRThemeInfo、SSRThemeInstallRequest、SSRThemeStartRequest 三个类型。
在src/hooks/queries/use-theme-mall.ts中,先给顶部的themeKeys加一个 key:
TYPESCRIPT
export const themeKeys = {
// ...原有 keys...
ssrList: () => [...themeKeys.all, "ssr-list"] as const,
};Queries 区域追加列表查询:
TYPESCRIPT
/** 获取已安装 SSR 主题列表 */
export function useInstalledSSRThemes() {
return useQuery({
queryKey: themeKeys.ssrList(),
queryFn: () => themeMallApi.getInstalledSSRThemes(),
});
}文件末尾追加 SSR 相关 mutations(至少需要useInstallSSRTheme,其余按需复制):
TYPESCRIPT
// ===== SSR Theme Mutations =====
/** 安装 SSR 主题 */
export function useInstallSSRTheme() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: SSRThemeInstallRequest) => themeMallApi.installSSRTheme(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: themeKeys.ssrList(), refetchType: "all" });
qc.invalidateQueries({ queryKey: themeKeys.installed(), refetchType: "all" });
},
});
}
/** 卸载 SSR 主题 */
export function useUninstallSSRTheme() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) => themeMallApi.uninstallSSRTheme(name),
onSuccess: () => {
qc.invalidateQueries({ queryKey: themeKeys.ssrList(), refetchType: "all" });
qc.invalidateQueries({ queryKey: themeKeys.installed(), refetchType: "all" });
qc.invalidateQueries({ queryKey: themeKeys.current(), refetchType: "all" });
},
});
}
/** 启动 SSR 主题(切换到此主题) */
export function useStartSSRTheme() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) => themeMallApi.startSSRTheme(name),
onSuccess: () => {
qc.invalidateQueries({ queryKey: themeKeys.all, refetchType: "all" });
},
});
}
/** 停止 SSR 主题 */
export function useStopSSRTheme() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) => themeMallApi.stopSSRTheme(name),
onSuccess: () => {
qc.invalidateQueries({ queryKey: themeKeys.ssrList(), refetchType: "all" });
},
});
}新建src/components/admin/themes/InstallSSRThemeDialog.tsx,完整内容:
TYPESCRIPT
"use client";
import { useState, useCallback, useMemo } from "react";
import { ModalBody, ModalFooter, Button, addToast } from "@heroui/react";
import { Zap, Link2, Hash, Tag, Info } from "lucide-react";
import { AdminDialog } from "@/components/admin/AdminDialog";
import { FormInput } from "@/components/ui/form-input";
import { cn } from "@/lib/utils";
interface InstallSSRThemeDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onInstall: (data: { themeName: string; downloadUrl: string; version?: string }) => Promise<void>;
isInstalling: boolean;
}
/** 从下载地址推导主题名(对齐后端 extractThemeNameFromUrl) */
function deriveThemeName(url: string): string {
const fileName = url.split("/").pop() || "";
const withoutExt = fileName
.replace(/\.tar\.gz$/i, "")
.replace(/\.tgz$/i, "")
.replace(/\.zip$/i, "");
return withoutExt.replace(/-v?\d+(\.\d+)*$/, "");
}
/** 预校验下载地址(对齐后端 util.ValidatePublicHTTPURL 的 SSRF 限制) */
function validateDownloadUrl(url: string): string | null {
if (!url.trim()) return null; // 空值由提交时必填校验处理
let parsed: URL;
try {
parsed = new URL(url.trim());
} catch {
return "URL 格式不正确";
}
if (parsed.protocol !== "https:") {
return "后端仅允许 HTTPS 协议的下载地址";
}
if (parsed.port && parsed.port !== "443") {
return "后端不允许使用非 443 端口";
}
const host = parsed.hostname.toLowerCase();
if (host === "localhost" || /\.(local|internal|localhost)$/i.test(host)) {
return "后端 SSRF 防护不允许本机/内网域名";
}
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(host)) {
if (/^(127\.|10\.|0\.)/.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host)) {
return "后端 SSRF 防护不允许回环/私网 IP,请使用公网 HTTPS 地址";
}
}
return null;
}
export function InstallSSRThemeDialog({
isOpen,
onOpenChange,
onInstall,
isInstalling,
}: InstallSSRThemeDialogProps) {
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [nameTouched, setNameTouched] = useState(false);
const [version, setVersion] = useState("");
const urlError = useMemo(() => validateDownloadUrl(url), [url]);
const effectiveName = nameTouched ? name : deriveThemeName(url);
const nameError = effectiveName && !/^theme-[a-z0-9-]+$/.test(effectiveName) ? "主题名需匹配 ^theme-[a-z0-9-]+$" : null;
const handleClose = useCallback(() => {
setUrl("");
setName("");
setNameTouched(false);
setVersion("");
onOpenChange(false);
}, [onOpenChange]);
const handleUrlChange = useCallback(
(value: string) => {
setUrl(value);
if (!nameTouched) setName("");
},
[nameTouched]
);
const handleInstall = useCallback(async () => {
const trimmedUrl = url.trim();
if (!trimmedUrl) {
addToast({ title: "请填写主题下载地址", color: "danger", timeout: 3000 });
return;
}
const urlErr = validateDownloadUrl(trimmedUrl);
if (urlErr) {
addToast({ title: urlErr, color: "danger", timeout: 5000 });
return;
}
const themeName = (nameTouched ? name : deriveThemeName(trimmedUrl)).trim();
if (!themeName) {
addToast({ title: "无法从地址推导主题名,请手动填写", color: "danger", timeout: 3000 });
return;
}
if (!/^theme-[a-z0-9-]+$/.test(themeName)) {
addToast({ title: "主题名必须以 theme- 开头,仅含小写字母、数字和连字符", color: "danger", timeout: 4000 });
return;
}
await onInstall({ themeName, downloadUrl: trimmedUrl, version: version.trim() || undefined });
handleClose();
}, [url, name, nameTouched, version, onInstall, handleClose]);
const canSubmit = !!url.trim() && !urlError && !!effectiveName && !nameError && !isInstalling;
return (
<AdminDialog
isOpen={isOpen}
onOpenChange={open => {
if (!open) handleClose();
else onOpenChange(open);
}}
size="md"
header={{ title: "通过 URL 安装 SSR 主题", description: "从下载地址安装 Next.js SSR 主题包", icon: Zap }}
>
<ModalBody className="gap-4">
{/* 说明 */}
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-muted/40 rounded-xl px-3 py-2.5">
<Info className="w-3.5 h-3.5 shrink-0 mt-px" />
<div className="space-y-1 leading-relaxed">
<p>
SSR 主题包(tar.gz,内含 <code className="font-mono">server.js</code>)不支持本地文件上传,
后端仅开放「从 URL 下载」的安装通道。
</p>
<p>地址要求:公网 HTTPS(443 端口)、拒绝本机/内网地址,压缩包不超过 256MB。</p>
<p className={cn("text-foreground/70")}>提示:可先将 tar.gz 上传到对象存储或站点文件管理,取得直链后填入。</p>
</div>
</div>
<FormInput
label="下载地址"
isRequired
autoFocus
value={url}
onValueChange={handleUrlChange}
placeholder="https://example.com/theme-byer-blog-1.0.4.tar.gz"
startContent={<Link2 className="w-4 h-4 text-muted-foreground/50" />}
error={urlError || undefined}
description="支持 .tar.gz / .tgz 格式的 SSR 主题包"
/>
<FormInput
label="主题名称"
isRequired
value={effectiveName}
onValueChange={v => {
setNameTouched(true);
setName(v);
}}
placeholder="theme-byer-blog"
startContent={<Hash className="w-4 h-4 text-muted-foreground/50" />}
error={url && !urlError ? nameError || undefined : undefined}
description="留空时自动从文件名推导(去掉扩展名与版本后缀)"
/>
<FormInput
label="版本号"
value={version}
onValueChange={setVersion}
placeholder="选填,如 1.0.4"
startContent={<Tag className="w-4 h-4 text-muted-foreground/50" />}
description="用于后台版本记录,可留空"
/>
</ModalBody>
<ModalFooter>
<Button variant="flat" onPress={handleClose}>
取消
</Button>
<Button color="primary" isDisabled={!canSubmit} isLoading={isInstalling} onPress={handleInstall}>
<Zap className="w-4 h-4 mr-1" />
安装主题
</Button>
</ModalFooter>
</AdminDialog>
);
}依赖说明:
AdminDialog和FormInput是本项目自己的封装组件(路径已写在 import 里)。如果你自己的后台项目没有同款组件,把<AdminDialog>换成 HeroUI 的<Modal>、<FormInput>换成<Input>即可,内部逻辑不变。
在主题管理页的页面逻辑 hook(本项目是src/app/admin/themes/_hooks/use-theme-page.ts)中,按下面五处插入:
TYPESCRIPT
// ① 文件顶部 import 区,补充:
import { useInstallSSRTheme } from "@/hooks/queries/use-theme-mall";
import type { SSRThemeInstallRequest } from "@/types/theme-mall";
// ② useThemePage() 内部的弹窗状态区,补充:
// ---- SSR 主题 URL 安装弹窗 ----
const [ssrInstallOpen, setSsrInstallOpen] = useState(false);
// ③ Mutations 区,补充:
const installSSR = useInstallSSRTheme();
// ④ 任意 handler 位置,补充提交逻辑:
// ---- 安装 SSR 主题(手动填写 URL,后端无本地文件上传通道) ----
const handleInstallSSRFromUrl = useCallback(
async (data: SSRThemeInstallRequest) => {
try {
await installSSR.mutateAsync(data);
addToast({ title: `SSR 主题 ${data.themeName} 安装成功`, color: "success", timeout: 3000 });
handleTabChange("installed"); // 成功后跳到已安装 Tab
} catch (e) {
addToast({ title: e instanceof Error ? e.message : "安装 SSR 主题失败", color: "danger", timeout: 5000 });
}
},
[installSSR, handleTabChange]
);
// ⑤ 最后记得在 return 对象里导出:
return {
// ...原有字段...
ssrInstallOpen,
setSsrInstallOpen,
handleInstallSSRFromUrl,
installSSR,
};然后在页面组件(本项目是src/app/admin/themes/page.tsx)里加按钮和弹窗渲染:
TYPESCRIPT
// ① 文件顶部 import:
import { InstallSSRThemeDialog } from "@/components/admin/themes/InstallSSRThemeDialog";
// ② 页面右上角按钮组(「上传主题」按钮左侧):
<Button
size="sm"
variant="flat"
startContent={<Zap className="w-3.5 h-3.5" />}
onPress={() => tp.setSsrInstallOpen(true)}
className="text-foreground/70"
>
SSR 安装
</Button>
// ③ 页面末尾(其他 Dialog 旁边):
{/* 通过 URL 安装 SSR 主题 */}
<InstallSSRThemeDialog
isOpen={tp.ssrInstallOpen}
onOpenChange={tp.setSsrInstallOpen}
onInstall={tp.handleInstallSSRFromUrl}
isInstalling={tp.installSSR.isPending}
/>在上传弹窗组件(本项目是src/components/admin/themes/UploadThemeDialog.tsx)里,把原本"非 zip 静默拒绝"的handleFile开头替换成:
TYPESCRIPT
const handleFile = useCallback((file: File) => {
if (!file.name.endsWith(".zip")) {
// SSR 主题为 tar.gz,不走本上传通道,需给出明确提示而非静默拒绝
if (/\.(tar\.gz|tgz)$/i.test(file.name)) {
addToast({
title: "SSR 主题包(tar.gz)不支持本地上传,请使用「SSR 安装」通过下载地址安装",
color: "warning",
timeout: 5000,
});
} else {
addToast({ title: "仅支持 ZIP 格式的主题压缩包", color: "danger", timeout: 3000 });
}
return;
}
// ...其余原有逻辑(体积校验等)保持不变...
}, []);在改完代码之后,点击SSR安装,点击安装主题即调 installSSRTheme mutation → /api/admin/ssr-theme/install,成功后刷新已安装列表。顺手把原上传弹窗的静默拒绝改成了明确提示:"SSR 主题包(tar.gz)不支持本地上传,请使用「SSR 安装」"——这种没有报错的失败真的最容易把人逼疯,受害者 experience 表示必须改。

装上了!点进前台一看,是我熟悉的主题!美滋滋!
然后下一秒,后台就没了。
启动与路由:官方后台 + 你的前台是怎么共存的
在说翻车之前,先讲清楚 SSR 主题跑起来后的路由分工。Go 侧的代理中间件(pkg/middleware/ssr_proxy.go)是这么划的:
请求 | 归谁 |
|---|---|
| 官方后台(永不进主题进程) |
| Go 后端 |
| Go 后端(主题里的同名 Route Handler 收不到请求) |
其余全部公开页面 | 你的 SSR 主题(反代到主题进程端口) |
所以"后台用官方、前台用我的"是架构默认行为,零配置。
副作用也挺有意思:主题项目里 src/app/admin/ 那一整套后台页面在这条链路里是"编译了但永远不被访问"的死代码——不影响运行,只是白白占包体积。另外注意:登录页也无法通过主题自定义(在排除清单里,平台有意保护认证流程)。
这张表先放这,记住 /admin "永不进主题进程"这句话——因为接下来第一个坑,就是这句话被生生打破了。
坑之一:3000 端口之争
无限崩溃重启
安装完的刚过一会,我发现后台没了(我一开始安装的时候后台还能看1-3s),我一打开日志就发现不对劲,嵌入式前端在无限崩溃重启:
PLAINTEXT
[SSR] 主题 HTTP 服务已就绪: theme-byer-blog
[Frontend] ✅ Next.js 前端已就绪 (等待了 0.1 秒)
[Frontend] Next.js 进程异常退出: exit status 1
[Frontend] 将在 2s 后尝试重启 ...(1s→2s→4s→8s→16s 指数退避)
一会"已就绪"一会"异常退出",给整得和仰卧起坐似的。
原因:嵌入式官方前端 DefaultPort = 3000,而 SwitchToSSRTheme 里硬编码 ssrManager.Start(themeName, 3000),两边抢同一个端口。主题先抢到 3000 后,嵌入式进程 bind 失败秒退(EADDRINUSE,去 runtime 目录的 frontend.log 可实锤);更迷惑的是 launcher 的健康探测 dial 127.0.0.1:3000 连上的是主题进程,于是每次重启都先报"已就绪"再退出——日志里那段精神分裂就是这么来的。好在 watchdog 有兜底:5 次/5 分钟后放弃重启。
错误的解法:ANHEYU_FRONTEND_URL
我的第一反应是设 ANHEYU_FRONTEND_URL=http://127.0.0.1:3000,让 Go 别再拉嵌入式进程了——你不是抢端口吗,我直接告诉你"前端已经活着了"。
冲突确实消失了,但后台沦陷了:/admin 被 ssr_proxy 排除后,会落到全局兜底代理 ProxyMiddleware,它的目标恰好就是"前端地址"——被指到 3000 之后,/admin 打开的是你主题里那套社区版后台(付费版后台就这么被顶掉了,刚解决的时候我还没发现问题,直到我上传文件时发现我是社区版的后台)。
正确的解法:动端口,别动 URL
main.go 支持 ANHEYU_FRONTEND_PORT,把嵌入式前端挪走、3000 留给主题(主题端口硬编码改不了,能动的是嵌入式那边):
YAML
services:
anheyu:
environment:
# - ANHEYU_FRONTEND_URL=http://127.0.0.1:3000 # ❌ 删掉
- ANHEYU_FRONTEND_PORT=3010 # 嵌入式官方前端(含付费后台)挪到 3010
改完:/admin → 3010 官方后台,前台 → 3000 你的主题,崩溃循环同时消失。
到这里我以为故事结束了。直到我打开后台,一片白。
坑之二:官方后台白屏——被主题吞掉的 chunk
端口冲突解决、/admin 能打开了,可一切到自己的 SSR 主题,后台又白屏,控制台只有一句:
PLAINTEXT
Uncaught SyntaxError: Unexpected token '<' (at 01bjw0q7sl~wq.js:1:1)
三条最容易带偏的"假线索"
这个错误我前后排查了整整一个晚,中间被三条假线索轮流带偏:
清 CDN 缓存没用——不是 CDN 的锅,源站本身就回 HTML(我一开始只清除了登录页面的js的缓存,发现能登陆了以为解决了问题,结果当我删除了全部缓存后,发现出现了大量的js错误);
无痕窗口照样报错——排除了 Service Worker / 浏览器缓存;
docker exec ... ps aux | grep node是空、docker logs | grep Frontend也是空——别信。容器是 alpine + busybox,ps/grep经常漏报;宿主机 curl 内嵌端口拿到000也不代表进程死了。要验就在容器内用 node 直接 fetch 问。
第三条最阴——它一度让我以为内嵌前端根本没起来,差点推倒整个方案重来。
回到源码:/_next 不在排除清单里
先搞清楚 Unexpected token '<' 的本质:某个 /_next/static/chunks/*.js 的请求拿回来的其实是 HTML(开头是 <),浏览器当脚本执行就崩。那为什么 JS 请求会回 HTML?还得回到那张路由分工表。
对照 pkg/middleware/ssr_proxy.go 的 ShouldSkipSSRProxy,主题激活时只有这些会留给 Go / 内嵌官方前端、不进主题进程:
PLAINTEXT
/api/、/admin、/login、/admin-static/、/admin-assets/、/f/、/needcache/
外加 /robots.txt、/sitemap.xml、/rss.xml、/feed.xml、/atom.xml 精确匹配
关键:/_next/ 不在这张表里。而官方后台 /admin 由内嵌前端(ANHEYU_FRONTEND_PORT,如 3010)渲染,它的 HTML 引用的是它自己构建的 /_next/static/chunks/*。浏览器请求这些 chunk 时,Go 发现不是排除前缀 → 反代进你的主题进程(3000);主题与官方前端是两次独立构建,chunk 名对不上 → 主题回自己的 404 HTML(还常被包成假 200)→ 后台白屏。
这里要注意区分版本:社区版的主题与内嵌官方前端同源同构建、大部分 chunk 名相同,只有极少数对不上;PRO 的后台是另一套构建,后台整片
/_nextchunk 主题都没有(所以本例是"一崩崩一大串"而非只挂一个)。扫资源只看 200 状态码必误判,一定要核对 Content-Type 与响应体开头——主题缺文件时回的是 200 的 HTML 页。
走过的弯路:src/proxy.ts 运行时转发
第一反应当然是做"运行时转发":在主题里加 src/proxy.ts,命中 /_next/static/* 时本地 fs.existsSync 有就放行、没有就转发回内嵌前端端口(ANHEYU_FRONTEND_PORT)。听起来很美好,写的时候才发现坑中坑:middleware.ts 在 Next 16 已废弃且仍走 Edge Runtime,node:fs / process.cwd 直接报错,必须改名 src/proxy.ts、默认导出、Node 运行时;
但真正致命的是:Next 16 standalone 压根不在 /_next/static 上调用 proxy——静态资源在更早的一层被直接服务,请求根本进不了 proxy 函数。所以「运行时转发」对 PRO 是死的。
社区版不用管这个问题,是因为它和内嵌前端同源同构建、chunk 名天然相同不会 miss;PRO 后台是另一套构建,整片 /_next chunk 主题都没有。绕了一大圈,最后还是得回到源码找答案。
真正有效的解法:物理合并 + 重启
关键认知是:主题对 /_next/static/* 的行为是——磁盘上有这个文件就作为 JS 直接吐出来,没有就落到主题的兜底 / [...slug] 路由,渲染成一整页 HTML(状态还是 200,不是 404)。
既然"有文件就吐",那直接把内嵌官方前端的 .next/static 补进主题的 .next/static 就完事了,完全不需要 proxy、不依赖 任何东西。
先确证(容器内、绕 CDN,对同一个 chunk 分别问主题 3000 和内嵌 3010):
BASH
docker exec anheyu-backend node -e "const u='/_next/static/chunks/<后台某个chunk>.js';Promise.all([fetch('http://127.0.0.1:3000'+u),fetch('http://127.0.0.1:3010'+u)]).then(async r=>console.log('theme3000=',r[0].status,r[0].headers.get('content-type'),'| embedded3010=',r[1].status,r[1].headers.get('content-type')))"
典型铁证:theme3000= 200 text/html(主题没有→渲成 HTML)、embedded3010= 200 application/javascript(文件本就属于内嵌前端)。看到这一行输出的瞬间,整个链路在我脑子里"咔哒"一声对上了,那个感觉整个人都通透了。
再补齐 + 重启(动态定位内嵌 static,hash 随 PRO 版本变):
BASH
docker exec anheyu-backend sh -c 'EMB=$(for d in /proc/[0-9]*; do e=$(readlink "$d/exe" 2>/dev/null); case "$e" in *node) p=$(tr "\0" "\n" < "$d/environ" 2>/dev/null | sed -n "s/^PORT=//p"); [ "$p" = "3010" ] && readlink "$d/cwd";; esac; done | head -1); ( cd "$EMB/.next/static" && tar cf - . ) | ( cd /anheyu/themes/theme-byer-blog/.next/static && tar xf - )'
docker restart anheyu-backend
几个细节一定要记住,都是经验之谈:
别用
cp -n:busybox 的cp不支持-n,命令直接报错却被echo COPIED掩盖、文件根本没拷进去——用tar管道整体合并最稳;内容哈希同名即同内容,覆盖无害、主题已有文件不受影响。必须重启:Next 在进程启动时索引静态清单,光拷文件不重启当前进程不认。
合并完 清 CDN(JS + HTML 一起清)+ 无痕复测。
这套逻辑已经固化成仓库脚本 scripts/sync-admin-static.sh:每次 PRO 升级后在宿主机跑一次即可。
SHELLSCRIPT
#!/bin/sh
#
# PRO 场景修复:把内嵌官方前端(/admin、/login)的 /_next/static 产物增量并入 SSR 主题目录,
# 再重启容器让主题进程重新索引静态资源。
#
# 用法(在宿主机执行):
# sh sync-admin-static.sh [容器名] [主题目录]
# 默认:容器名=anheyu-backend,主题目录=/anheyu/themes/theme-byer-blog
# PRO 每次升级后跑一次即可(frontend-runtime/<hash> 的 hash 会变,脚本会动态探测)。
#
# 注意:内容哈希同名即同内容,合并覆盖无害;主题自身已有文件不受影响。
set -e
CT="${1:-anheyu-backend}"
THEME="${2:-/anheyu/themes/theme-byer-blog}"
echo "[sync] 容器=$CT 主题=$THEME"
# 在容器内:动态定位正在监听 3010 的内嵌前端 next-server 进程 cwd,即内嵌静态根
docker exec "$CT" sh -c '
EMB=$(for d in /proc/[0-9]*; do
e=$(readlink "$d/exe" 2>/dev/null)
case "$e" in
*node)
p=$(tr "\0" "\n" < "$d/environ" 2>/dev/null | sed -n "s/^PORT=//p")
[ "$p" = "3010" ] && readlink "$d/cwd"
;;
esac
done | head -1)
if [ -z "$EMB" ] || [ ! -d "$EMB/.next/static" ]; then
echo "[sync] 错误:未找到内嵌前端静态目录(EMB=$EMB)。确认 ANHEYU_FRONTEND_PORT=3010 且内嵌前端在运行。"
exit 1
fi
mkdir -p "'"$THEME"'/.next/static"
# busybox 的 cp 不支持 -n;用 tar 管道整体合并(同名覆盖无害)
( cd "$EMB/.next/static" && tar cf - . ) | ( cd "'"$THEME"'/.next/static" && tar xf - )
echo "[sync] 已从 $EMB/.next/static 合并到主题目录"
'
echo "[sync] 重启容器,让主题进程重新索引静态资源 ..."
docker restart "$CT"
cat <<'EOF'
[sync] 完成。请接着:
1) 清 CDN 缓存(JS + HTML 都清);
2) 浏览器无痕窗口打开 /admin 复测;
3) 如仍有个别 chunk 报错,重跑本脚本即可(tar 会补齐所有内嵌 chunk/media)。
EOF
长期最干净的修法其实是让 PRO 后台资源走
/admin-static/、/admin-assets/(这两个前缀在ShouldSkipSSRProxy里、不受主题影响)——那属上游能力,可给 anheyu-pro 提 issue;在没支持前,物理合并是当前可靠的兜底。
一些零碎的坑和 FAQ
主线讲完了,再补几个散落在过程里、但迟早会有人撞上的小坑。
Q:已安装列表里我的 SSR 主题卡片为什么是"SSR Theme / v1.0.0 / 暂无预览图"的裸奔样式? A:已安装卡片 = 文件系统扫描结果(只有 name/status/port)+ 按名称去商城列表匹配的元信息。URL 直装的主题没有商城记录可匹配,作者/版本/预览图全部掉进前端兜底值。想显示完整:走官方渠道上架商城,或给后端提需求让 ListInstalled 读主题目录里的 public/theme.json。
Q:主题包能加密防盗吗? A:不能,也不必。SSR 主题本质是在买家服务器上执行的 Node 程序——能在他机器上运行的代码就能在他机器上还原,而且后端 extractTarGz 只接受明文包,加密直接装不上。现实手段是:官方商城分发(下载链接由平台鉴权)、OSS 短时效签名直链、按订单注入买家水印做溯源、把核心功能放自己的服务端 API。
Q:PRO 功能字段(付费文章等)在主题里怎么适配? A:前台代码用 isProEdition() 探测后端版本,PRO 主题声明 "themeType": "pro";在社区版实例上安装 PRO 主题会收到"仅限 PRO 版本用户"的提示。
Q:我多次上传了主题,为什么只有之前第一次传的主题的样式? A:假如你已经安装了一次主题了,你需要对public/theme.json的版本进行更新,不能一直使用旧版本。
总结
芜湖,这篇文章终于写完啦!
毕竟时隔十个月再写博客,第一件事是想起自己博客的图床密码是什么(笑)。
回头看整个折腾过程,其实最大的感受其实不是"坑多",而是:当文档没写清楚的时候,源码永远是唯一不会骗你的。
还有一个小小的、偏题的感慨:时隔十个月重新坐下来写文章,比想象中难。技术内容其实都躺在备忘录里,难的是找回那种"把一件事从头讲给对方听"的节奏——先讲为什么做,再讲怎么做,中间承认自己哪里想岔了。写完这篇,更新频率应该会慢慢回来一些吧,毕竟坑还有一堆没写(有可能再次消失)。
那么,就到这里啦!下一篇见~
完整源码参考:
主仓库(Go 后端 + 子模块结构):https://github.com/anzhiyu-c/anheyu-app


