diff --git a/.cursor/project_memory.md b/.cursor/project_memory.md index a131be8..8897b36 100644 --- a/.cursor/project_memory.md +++ b/.cursor/project_memory.md @@ -25,6 +25,89 @@ --- +## 官方运行时(用户 2026-09-18 冻结,跨会话必须延续,未开工) + +管理员可在后台一键更新 **不做本期**。先做「官方运行时」:一种可替换的交付形态。运行时契约冻住后很少改;论坛功能走新镜像 / 新包。用户主路径是 Docker `pull` + `up`。有空再按下列阶段开发,未轮到的阶段不要提前做。 + +### 已对齐的产品结论 + +- **两层分开**:运行时 = Dockerfile / Compose / volume / 环境变量;应用 = Go + Next。反代不进官方 Compose,由部署方自备。用户日常更新应用,不重装运行时。 +- **官方运行时 = Docker Compose 一体交付**(`web` + `api` + Postgres)。预编译包是同一目录布局的第二种皮,排在 Docker 之后。 +- **真 SSR 不能扔**:Next.js 16 必须有 Node 进程,禁止 `output:'export'` 塞进 Go 当静态站。 +- **必须同一 Host**:页面、`/api/`、`/api/ws`、`/uploads` 同域;`__Host-` cookie 与 WS Origin 不允许 API 另开子域。 +- **数据与程序分离**:Postgres volume、`data/`(上传与 JWT 密钥)、配置(env / 挂载的 `app.ini`)一律不进镜像。 +- **本地开发不动**:仓库根现有 `docker-compose.yml` 只起 Postgres,配合 `start.bat` / `cd backend && go run ./cmd/jiang13` + `frontend` `npm run dev`。官方运行时用另一份 Compose(如 `deploy/docker-compose.yml`),禁止把开发流程改成「本地也必须打应用镜像」。 +- **更新方式**:Docker 用户 `docker compose pull && docker compose up -d`;启动时既有 `AutoMigrate` 即可。接受短暂停机,上线前仍禁止为双版本并存写兼容垫片。 +- **明确不做**:源码机上 `git pull` + `go build` / `next build` 当官方路径;把 Docker socket 交给 Web 进程;Cloudflare Workers 拆分部署当官方运行时(可当高级可选项,不走一键更新);后台一键替换进程(等运行时契约冻住且确有非 Docker 用户再单独立项)。 + +### 目标拓扑(官方 Compose) + +``` +浏览器 ──同 Host──► 宿主机反代(Nginx/Caddy,自备) + ├─ 页面 / RSC → 127.0.0.1:3000(web) + ├─ /api /api/ws → 127.0.0.1:3001(api) + └─ /uploads → 127.0.0.1:3001(api) +Postgres volume ──► postgres:17 +data volume ──► Go 的 dataDir(与本地 backend/data 语义一致) +``` + +- Go 工作目录 / `JIANG13_WORK_PATH` 对准容器内数据根,启动日志须含 `workPath` + `dataDir`。 +- `DEV_MODE=false`;`JWT_SECRET`、DSN、站点 URL 走环境变量,镜像不含真实 `app.ini`。 +- 健康检查:Go `/health` 与 Next `/healthz`;反代须透传 `Upgrade` / `Connection`,保留 `Host` / `Origin`。 +- Compose **不**跑 Nginx/Caddy。示例配置:`deploy/nginx.example.conf`。 + +### 落地进度(2026-09-18) + +阶段 1–3 已落地:`deploy/` 为 `web` + `api` + `postgres`,不含反代;宿主机 Nginx 示例见 `deploy/nginx.example.conf`。仓库根 `docker-compose.yml` / `start.bat` 未改。阶段 4–6 仍未做。 + +### 开发阶段(按序,未点名不做) + +**阶段 1 — 版本与契约(开工第一步)** + +- 前后端同一 semver:Go `ldflags` 注入;前端构建期写入(勿再用页脚空占位当正式版本)。 +- 写死运行时契约:服务名、端口、volume 名、环境变量表、数据目录含义。契约变更视为运行时大版本,须 changelog。 +- Release 产物:带 tag 的镜像 + 校验和;changelog;**最低可升级版本**(跨大版本禁止跳迁)。 + +**阶段 2 — 官方 Docker 运行时(主交付)** + +- 多阶段 Dockerfile:编 Go 二进制、编 Next `standalone`,运行镜像不含源码与工具链。 +- `deploy/docker-compose.yml`:`web` + `api` + `postgres`。反代不进 Compose,由部署方用 Nginx 等同源反代。 +- volume:`pgdata`、`appdata`(映射到 Go `dataDir`)。配置只挂 env / 可选 `app.ini`。 +- 首次 `up` 即迁移 + 可登录;文档只写 Compose 步骤,不要求宿主机装 Go/Node。 +- 回归:同 Host cookie、WS 握手、头像 `/uploads`、重启容器数据还在。 + +**阶段 3 — 文档化更新(Docker 用户的官方升级)** + +- 标准命令:`docker compose pull && docker compose up -d`。 +- 说明:短暂停机、备份(pg dump + `appdata`)、失败则改回上一 tag。 +- Compose 文件本身尽量向前兼容(新 env 有默认);真要改文件时 changelog 写「请合并 compose」,与「只 pull 应用镜像」分开。 + +**阶段 4 — 预编译包(可选,布局与镜像内一致)** + +- 包内:`jiang13`(或 `.exe`)、Next standalone、示例配置、watchdog/supervisor。 +- Windows 不能覆盖正在运行的 exe,必须靠外层 watchdog 换进程;Linux 可 `exec` 或 systemd。 +- 与 Docker 共用版本号与数据目录语义;更新仍是换文件 + 重启,不是本机编译。 + +**阶段 5 — 后台「检查更新」(只读,非一键)** + +- 仅 `owner`:当前版本、频道(稳定/测试)、changelog、最低可升级版本。 +- Docker 用户展示官方 `pull && up` 说明;不支持的部署方式如实说「请按文档升级」。 +- 不做下载、不做替换、不暴露 Docker socket。 + +**阶段 6 — 面板一键更新(明确延后,勿提前做)** + +- 仅官方 Docker(updater sidecar,禁止 app 挂 docker.sock)或官方 watchdog 包。 +- 站长 + CSRF + 验签 + 审计;先备份再切;健康检查失败回滚。 +- 源码部署 / Workers / 用户自管 systemd:永远只显示「当前部署不支持在线更新」。 + +### 开工时注意 + +- 先改契约与 `deploy/`,不要改 `jiang13-bbs-local-dev` 的 cwd / 端口铁律。 +- 镜像构建在 CI 或维护者机器;不要把 `node_modules`、`.next`、`backend/data`、真实 `app.ini` 打进 git 或镜像。 +- 生产 `DEV_MODE=false`;反代必须同源。Workers 前端不是官方运行时验收范围。 + +--- + ## Next.js 16 关键约束 - params/searchParams/cookies()/headers()/draftMode() 必须 await diff --git a/.cursor/rules/jiang13-bbs-local-dev.mdc b/.cursor/rules/jiang13-bbs-local-dev.mdc index bec1d0a..e2fb3a4 100644 --- a/.cursor/rules/jiang13-bbs-local-dev.mdc +++ b/.cursor/rules/jiang13-bbs-local-dev.mdc @@ -64,3 +64,7 @@ npm.cmd run dev ## 遗留目录 `backend/cmd/jiang13/data/` 是错误 cwd 的产物,禁止当数据源。发现后把 `uploads/` 合并进 `backend/data/uploads/`,再删遗留目录。 + +## 官方运行时(与本地开发分开) + +生产交付在 `deploy/`(Next standalone + Go + Postgres,反代自备),契约见 `deploy/CONTRACT.md`。仓库根 `docker-compose.yml` **只**给本地 Postgres 用。禁止把 `start.bat` / `go run` / `npm run dev` 改成必须打应用镜像;禁止对开发库执行 `deploy` 目录的 `docker compose down -v`。 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..948b87b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.cursor +**/.env +**/.env.local +**/.env.*.local +frontend +frontend/node_modules +frontend/.next +backend/data +backend/cmd/jiang13/data +backend/app.ini +backend/**/*_test.go +*.md +*.exe +*.test diff --git a/README.md b/README.md index bfd98f2..b12b00f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ | 后端 | Go 1.27 · Gin · GORM · PostgreSQL 17 | | 前端 | Next.js 16 (App Router) · TypeScript · Tailwind CSS v4 | | 认证 | JWT (HttpOnly Cookie) + Refresh Token 轮转 + CSRF 双提交 + Token Version 撤销 | -| 部署 | Docker Compose(数据库)· 可部署至 Cloudflare Workers(前端) | +| 部署 | 官方:`deploy/` Docker Compose(Next SSR + Go + Postgres,反代自备);本地开发仅 Compose 起数据库 | --- @@ -91,7 +91,8 @@ jiang13-bbs/ │ ├── middleware.ts # SSR 登录态静默轮转 │ ├── next.config.ts # Rewrite 代理 / 图片缓存 │ └── .env.example -├── docker-compose.yml # PostgreSQL 容器 +├── deploy/ # 官方运行时(web + api + Postgres,反代自备) +├── docker-compose.yml # 仅本地开发 Postgres ├── LICENSE # 专有软件许可协议 └── .gitignore ``` @@ -199,13 +200,25 @@ NEXT_PUBLIC_API_URL=http://localhost:3001 ## 部署 +**官方运行时是 `deploy/docker-compose.yml`(Next SSR + Go + Postgres,不含反代)。** 说明与升级命令见 [deploy/README.md](./deploy/README.md),Nginx 示例见 [deploy/nginx.example.conf](./deploy/nginx.example.conf),契约见 [deploy/CONTRACT.md](./deploy/CONTRACT.md)。 + +本地开发仍用仓库根 `start.bat` / `start.sh` 与根目录 `docker-compose.yml`(只起 Postgres)。不要把开发流程改成必须打应用镜像。 + +更新应用(镜像已发布时): + +```bash +cd deploy +docker compose pull +docker compose up -d +``` + +后台一键更新、预编译包不在本期。Cloudflare Workers 不是官方运行时。 + ### 生产环境要点 -1. **后端:** 设置 `DEV_MODE=false`,指定强随机 `JWT_SECRET`,通过反向代理(Nginx/Caddy)提供 HTTPS。 -2. **前端:** 设置 `BACKEND_URL` 为后端 HTTPS 地址,`npm run build` 后部署。Cloudflare Workers 部署需保持 `middleware.ts` 文件名不变(OpenNext 不识别 `proxy.ts`)。 -3. **数据库:** 修改默认密码,启用 SSL 连接。 -4. **CORS:** 生产环境修改 `router.go` 中的 `AllowOrigins` 为实际域名。 -5. **WebSocket:** 必须与页面同一 Host(见下节),不要把 API 单独放到另一个子域。 +1. **官方 Compose:** `DEV_MODE=false`,强随机 `JWT_SECRET` 与 `POSTGRES_PASSWORD`;由你现有的 Nginx/Caddy 提供 HTTPS,并把页面与 `/api`、`/api/ws`、`/uploads` 挂到同一 Host。 +2. **CORS:** 设 `SITE_URL` 为对外 origin(不要改 `router.go` 写死域名)。 +3. **自管部署:** 仍须同源反代。Workers 前端须保持 `middleware.ts` 文件名(OpenNext 不识别 `proxy.ts`),实时通道由反代承接 `/api/ws`。 ### WebSocket(开发 vs 生产) diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/backend/cmd/jiang13/main.go b/backend/cmd/jiang13/main.go index 0bffc7e..8e55be4 100644 --- a/backend/cmd/jiang13/main.go +++ b/backend/cmd/jiang13/main.go @@ -8,6 +8,7 @@ import ( "github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/router" "github.com/freefire/jiang13-bbs/service" + "github.com/freefire/jiang13-bbs/version" ) func main() { @@ -33,7 +34,7 @@ func main() { } addr := fmt.Sprintf(":%d", cfg.Port) - log.Printf("[jiang13-bbs] 服务启动于 %s (dev=%v workPath=%s dataDir=%s)", addr, cfg.DevMode, cfg.WorkPath, cfg.DataDir) + log.Printf("[jiang13-bbs] 服务启动于 %s (version=%s commit=%s dev=%v workPath=%s dataDir=%s)", addr, version.Version, version.Commit, cfg.DevMode, cfg.WorkPath, cfg.DataDir) if err := r.Run(addr); err != nil { log.Fatalf("服务启动失败: %v", err) } diff --git a/backend/config/config.go b/backend/config/config.go index 7b11840..0502ca1 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -12,12 +12,14 @@ import ( // Config 应用全局配置 type Config struct { - WorkPath string - Port int - DataDir string - JWTSecret string - DBDSN string - DevMode bool + WorkPath string + Port int + DataDir string + JWTSecret string + DBDSN string + DevMode bool + SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用 + CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量) } // Parse 解析配置:环境变量 > app.ini > 默认值。 @@ -84,6 +86,19 @@ func Parse() (*Config, error) { if v := os.Getenv("DEV_MODE"); v != "" { cfg.DevMode = strings.EqualFold(v, "true") || v == "1" } + if v := strings.TrimSpace(os.Getenv("SITE_URL")); v != "" { + cfg.SiteURL = strings.TrimRight(v, "/") + } + if v := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); v != "" { + cfg.CORSOrigins = splitCSVOrigins(v) + } + if v := strings.TrimSpace(os.Getenv("DATA_DIR")); v != "" { + abs, err := filepath.Abs(v) + if err != nil { + return nil, fmt.Errorf("解析 DATA_DIR 失败: %w", err) + } + cfg.DataDir = abs + } if err := rejectStrayDataDir(cfg.DataDir); err != nil { return nil, err @@ -108,6 +123,40 @@ func Parse() (*Config, error) { return cfg, nil } +// AllowOrigin 供 CORS 中间件判断浏览器 Origin。 +// 开发态只放行 localhost / 127.0.0.1 的 3000 端口(与 next dev 一致); +// 生产态放行 SITE_URL 与 CORS_ORIGINS。同源反代下浏览器不依赖 CORS,此列表作兜底。 +func (c *Config) AllowOrigin(origin string) bool { + if origin == "" { + return false + } + origin = strings.TrimRight(origin, "/") + if c.DevMode { + return origin == "http://localhost:3000" || origin == "http://127.0.0.1:3000" + } + if c.SiteURL != "" && origin == c.SiteURL { + return true + } + for _, o := range c.CORSOrigins { + if origin == o { + return true + } + } + return false +} + +func splitCSVOrigins(v string) []string { + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + s := strings.TrimRight(strings.TrimSpace(p), "/") + if s != "" { + out = append(out, s) + } + } + return out +} + // resolveWorkPath 定位后端根目录(app.ini 与 data/ 所在处)。 // 优先级:JIANG13_WORK_PATH > 从 cwd 向上找 app.ini > 源码树 backend/ > cwd。 func resolveWorkPath() (string, error) { diff --git a/backend/config/config_test.go b/backend/config/config_test.go index c38d621..f97c052 100644 --- a/backend/config/config_test.go +++ b/backend/config/config_test.go @@ -88,3 +88,54 @@ func TestRejectStrayDataDir(t *testing.T) { t.Fatal(err) } } + +func TestAllowOriginDevAndProd(t *testing.T) { + dev := &Config{DevMode: true} + if !dev.AllowOrigin("http://localhost:3000") || !dev.AllowOrigin("http://127.0.0.1:3000") { + t.Fatal("开发态应放行 next dev Origin") + } + if dev.AllowOrigin("https://bbs.example.com") { + t.Fatal("开发态不应放行生产域名") + } + + prod := &Config{ + DevMode: false, + SiteURL: "https://bbs.example.com", + CORSOrigins: []string{"https://mirror.example.com"}, + } + if !prod.AllowOrigin("https://bbs.example.com/") { + t.Fatal("生产态应放行 SITE_URL") + } + if !prod.AllowOrigin("https://mirror.example.com") { + t.Fatal("生产态应放行 CORS_ORIGINS") + } + if prod.AllowOrigin("http://localhost:3000") { + t.Fatal("生产态不应放行 localhost") + } +} + +func TestParseSiteURLAndDataDir(t *testing.T) { + work := t.TempDir() + data := t.TempDir() + t.Setenv("JIANG13_WORK_PATH", work) + t.Setenv("DEV_MODE", "false") + t.Setenv("SITE_URL", "https://bbs.example.com/") + t.Setenv("CORS_ORIGINS", " https://a.example.com ,https://b.example.com/ ") + t.Setenv("DATA_DIR", data) + t.Setenv("JWT_SECRET", "test-secret-not-for-prod") + + cfg, err := Parse() + if err != nil { + t.Fatal(err) + } + if cfg.DevMode { + t.Fatal("DEV_MODE=false 应关闭开发态") + } + if cfg.SiteURL != "https://bbs.example.com" { + t.Fatalf("SITE_URL 未去尾斜杠: %q", cfg.SiteURL) + } + if len(cfg.CORSOrigins) != 2 || cfg.CORSOrigins[0] != "https://a.example.com" || cfg.CORSOrigins[1] != "https://b.example.com" { + t.Fatalf("CORS_ORIGINS 解析错误: %#v", cfg.CORSOrigins) + } + assertPath(t, cfg.DataDir, data) +} diff --git a/backend/handler/admin_user.go b/backend/handler/admin_user.go index 132e418..bd3c0d8 100644 --- a/backend/handler/admin_user.go +++ b/backend/handler/admin_user.go @@ -26,6 +26,8 @@ func (h *Handlers) AdminListUsers(c *gin.Context) { Keyword: strings.TrimSpace(c.Query("q")), Role: c.Query("role"), Status: c.Query("status"), + Sort: c.Query("sort"), + Dir: c.Query("order"), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "获取用户列表失败"}) diff --git a/backend/handler/health.go b/backend/handler/health.go index 825bf20..6d401c6 100644 --- a/backend/handler/health.go +++ b/backend/handler/health.go @@ -4,14 +4,23 @@ import ( "net/http" "github.com/freefire/jiang13-bbs/model" + "github.com/freefire/jiang13-bbs/version" "github.com/gin-gonic/gin" ) -// Health 健康检查 +// Health 健康检查(官方运行时 Docker HEALTHCHECK 与反代 /health) func (h *Handlers) Health(c *gin.Context) { if err := model.PingDB(); err != nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"status": "error", "error": err.Error()}) + c.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "error", + "error": err.Error(), + "version": version.Version, + }) return } - c.JSON(http.StatusOK, gin.H{"status": "ok"}) + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "version": version.Version, + "commit": version.Commit, + }) } diff --git a/backend/handler/setting.go b/backend/handler/setting.go index f998acf..0482d0a 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -41,8 +41,10 @@ type updateSettingsRequest struct { AttachmentMaxMB *int `json:"attachment_max_mb"` AttachmentMaxCount *int `json:"attachment_max_count"` ImageMaxMB *int `json:"image_max_mb"` - CustomCSS *string `json:"custom_css"` - CustomJS *string `json:"custom_js"` + BgSiteURL *string `json:"bg_site_url"` + BgSiteMode *string `json:"bg_site_mode"` + BgAdminURL *string `json:"bg_admin_url"` + BgAdminMode *string `json:"bg_admin_mode"` TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播 } @@ -68,8 +70,10 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H { "attachment_max_mb": saved.AttachmentMaxMB, "attachment_max_count": saved.AttachmentMaxCount, "image_max_mb": saved.ImageMaxMB, - "custom_css": saved.CustomCSS, - "custom_js": saved.CustomJS, + "bg_site_url": saved.BgSiteURL, + "bg_site_mode": saved.BgSiteMode, + "bg_admin_url": saved.BgAdminURL, + "bg_admin_mode": saved.BgAdminMode, } } @@ -80,8 +84,9 @@ func (req *updateSettingsRequest) hasAny() bool { req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil || req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil || req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil || - req.AttachmentMaxCount != nil || req.ImageMaxMB != nil || req.CustomCSS != nil || - req.CustomJS != nil || req.TimelineGitImport != nil + req.AttachmentMaxCount != nil || req.ImageMaxMB != nil || + req.BgSiteURL != nil || req.BgSiteMode != nil || + req.BgAdminURL != nil || req.BgAdminMode != nil || req.TimelineGitImport != nil } // AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings) @@ -269,20 +274,32 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } } - if req.CustomCSS != nil { - if err := h.Setting.SetCustomCSS(*req.CustomCSS); err != nil { + if req.BgSiteURL != nil { + if err := h.applyBgURL(service.BgSurfaceSite, *req.BgSiteURL); err != nil { + writeBgSettingError(c, err) + return + } + } + if req.BgAdminURL != nil { + if err := h.applyBgURL(service.BgSurfaceAdmin, *req.BgAdminURL); err != nil { + writeBgSettingError(c, err) + return + } + } + if req.BgSiteMode != nil { + if err := h.Setting.SetBgSiteMode(*req.BgSiteMode); err != nil { if errors.Is(err, service.ErrInvalidSiteSetting) { - c.JSON(http.StatusBadRequest, gin.H{"error": "自定义 CSS 过长或含非法闭合标签"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的背景铺放模式"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) return } } - if req.CustomJS != nil { - if err := h.Setting.SetCustomJS(*req.CustomJS); err != nil { + if req.BgAdminMode != nil { + if err := h.Setting.SetBgAdminMode(*req.BgAdminMode); err != nil { if errors.Is(err, service.ErrInvalidSiteSetting) { - c.JSON(http.StatusBadRequest, gin.H{"error": "自定义 JS 过长或含非法闭合标签"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的背景铺放模式"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) @@ -318,3 +335,46 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { } c.JSON(http.StatusOK, out) } + +func writeBgSettingError(c *gin.Context, err error) { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "背景图地址无效"}) + return + } + if errors.Is(err, service.ErrBgFileMissing) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) +} + +// applyBgURL 写入对应轨的背景 URL,并删除不再被引用的旧文件 +func (h *Handlers) applyBgURL(surface, url string) error { + u, ok := service.NormalizeBgURL(url) + if !ok { + return service.ErrInvalidSiteSetting + } + if u != "" && !h.Upload.BackgroundFileExists(u) { + return service.ErrBgFileMissing + } + oldSite, err := h.Setting.BgSiteURL() + if err != nil { + return err + } + oldAdmin, err := h.Setting.BgAdminURL() + if err != nil { + return err + } + if surface == service.BgSurfaceAdmin { + if err := h.Setting.SetBgAdminURL(u); err != nil { + return err + } + h.Upload.RemoveBackgroundIfUnused(oldAdmin, oldSite, u) + return nil + } + if err := h.Setting.SetBgSiteURL(u); err != nil { + return err + } + h.Upload.RemoveBackgroundIfUnused(oldSite, u, oldAdmin) + return nil +} diff --git a/backend/handler/upload.go b/backend/handler/upload.go index 5f33600..f13ab9f 100644 --- a/backend/handler/upload.go +++ b/backend/handler/upload.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/realtime" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" ) @@ -165,3 +166,67 @@ func (h *Handlers) DeleteAttachment(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"ok": true}) } + +// UploadBackground 超管上传站点背景图(JPEG/PNG/WebP),并立即绑定前台或后台 +func (h *Handlers) UploadBackground(c *gin.Context) { + surface, ok := service.NormalizeBgSurface(c.PostForm("surface")) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "请指定前台或后台(surface=site|admin)"}) + return + } + + limit := int64(service.BackgroundMaxBytes + 4096) + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit) + + fh, err := c.FormFile("file") + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "背景图不能超过 8MB"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大或格式不正确"}) + return + } + if fh.Size > service.BackgroundMaxBytes { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "背景图不能超过 8MB"}) + return + } + f, err := fh.Open() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"}) + return + } + defer f.Close() + + url, err := h.Upload.SaveBackground(f) + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "背景图不能超过 8MB"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.applyBgURL(surface, url); err != nil { + writeBgSettingError(c, err) + return + } + + saved, err := h.Setting.Public() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"}) + return + } + payload := settingsPayload(saved) + h.Hub.BroadcastAll(realtime.Envelope{ + Type: realtime.EventSettingsChanged, + Data: payload, + }) + out := settingsPayload(saved) + out["ok"] = true + out["url"] = url + c.JSON(http.StatusOK, out) +} diff --git a/backend/model/db.go b/backend/model/db.go index 8525c59..4e1c3e8 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -270,7 +270,8 @@ func dropLegacyRefreshTokenColumn(db *gorm.DB) error { } // ensureOwnerRole 若无站长,则把 id 最小的旧管理员升级为站长; -// 连管理员都没有的全新库,把 id=1 的初始账号设为站长 +// 连管理员都没有则升级 id 最小的账号。空库(尚无用户)直接返回,等首次注册。 +// 禁止 GORM Update+Limit:无 WHERE 主键时会生成全表 UPDATE 并报 WHERE conditions required。 func ensureOwnerRole(db *gorm.DB) error { var ownerCount int64 if err := db.Model(&User{}).Where("role = ?", RoleOwner).Count(&ownerCount).Error; err != nil { @@ -279,16 +280,20 @@ func ensureOwnerRole(db *gorm.DB) error { if ownerCount > 0 { return nil } - res := db.Model(&User{}).Where("role = ?", RoleAdmin). - Order("id ASC").Limit(1).Update("role", RoleOwner) - if res.Error != nil { - return res.Error + + var candidate User + err := db.Select("id").Where("role = ?", RoleAdmin).Order("id ASC").First(&candidate).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + err = db.Select("id").Order("id ASC").First(&candidate).Error } - if res.RowsAffected == 0 { - if err := db.Model(&User{}).Order("id ASC").Limit(1). - Update("role", RoleOwner).Error; err != nil { - return err - } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + if err := db.Model(&User{}).Where("id = ?", candidate.ID).Update("role", RoleOwner).Error; err != nil { + return err } log.Println("[model] 已迁移初始账号为站长角色(owner)") return nil diff --git a/backend/router/router.go b/backend/router/router.go index fe53b7f..4906109 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -31,9 +31,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { // 全局安全响应头 r.Use(middleware.SecurityHeaders()) - // CORS + // CORS:开发放行 localhost:3000;生产用 SITE_URL / CORS_ORIGINS(同源反代时浏览器不走跨域) r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"http://localhost:3000", "http://127.0.0.1:3000"}, + AllowOriginFunc: cfg.AllowOrigin, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "X-CSRF-Token"}, AllowCredentials: true, @@ -266,6 +266,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { // 站点外观设置(超级管理员/站长);含 timeline_git_import staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings) staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings) + staffAPI.POST("/upload/background", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackground) // 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史 usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers)) diff --git a/backend/service/admin_user.go b/backend/service/admin_user.go index 9789549..aa0526c 100644 --- a/backend/service/admin_user.go +++ b/backend/service/admin_user.go @@ -2,10 +2,13 @@ package service import ( "errors" + "fmt" + "strings" "time" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // 管理员用户操作的业务护栏(handler 层映射为 4xx,前端只展示消息) @@ -86,6 +89,43 @@ type AdminUserListQuery struct { Keyword string // 用户名 / 昵称 / 邮箱模糊匹配 Role string // "" 全部 | "staff" 管理团队 | 具体角色枚举 Status string // "" 全部 | "banned" 已封禁 | "normal" 正常 + Sort string // 白名单列:id / role / status / points / last_login / counts / created_at / online + Dir string // asc | desc;非法值按 desc +} + +// adminUserOrderSQL 生成 ORDER BY 表达式。sort/dir 均走白名单,默认 id DESC(与历史行为一致)。 +func adminUserOrderSQL(sort, dir string) string { + ord := "DESC" + if strings.EqualFold(strings.TrimSpace(dir), "asc") { + ord = "ASC" + } + switch strings.TrimSpace(sort) { + case "role": + return fmt.Sprintf( + "CASE users.role WHEN 'owner' THEN 100 WHEN 'super_admin' THEN 80 WHEN 'admin' THEN 50 WHEN 'board_admin' THEN 30 ELSE 0 END %s, users.id DESC", + ord, + ) + case "status": + return fmt.Sprintf("users.banned %s, users.id DESC", ord) + case "points": + return fmt.Sprintf("users.points %s, users.id DESC", ord) + case "created_at": + return fmt.Sprintf("users.created_at %s, users.id DESC", ord) + case "online": + return fmt.Sprintf("users.last_seen_at %s NULLS LAST, users.id DESC", ord) + case "last_login": + return fmt.Sprintf( + "(SELECT MAX(created_at) FROM login_logs WHERE login_logs.user_id = users.id AND login_logs.success = true) %s NULLS LAST, users.id DESC", + ord, + ) + case "counts": + return fmt.Sprintf( + "(SELECT COUNT(*) FROM posts WHERE posts.user_id = users.id AND posts.status = '%s' AND posts.deleted_at IS NULL) %s, users.id DESC", + model.ContentStatusPublished, ord, + ) + default: + return "users.id " + ord + } } // AdminUserListResult 分页结果 + 汇总 @@ -129,7 +169,9 @@ func (s *AdminUserService) List(q AdminUserListQuery) (*AdminUserListResult, err } var users []model.User - if err := query.Order("id DESC"). + if err := query.Clauses(clause.OrderBy{ + Expression: clause.Expr{SQL: adminUserOrderSQL(q.Sort, q.Dir)}, + }). Offset((q.Page - 1) * q.Size).Limit(q.Size). Find(&users).Error; err != nil { return nil, err diff --git a/backend/service/admin_user_sort_test.go b/backend/service/admin_user_sort_test.go new file mode 100644 index 0000000..b224989 --- /dev/null +++ b/backend/service/admin_user_sort_test.go @@ -0,0 +1,46 @@ +package service + +import ( + "strings" + "testing" + + "github.com/freefire/jiang13-bbs/model" +) + +func TestAdminUserOrderSQLDefault(t *testing.T) { + got := adminUserOrderSQL("", "") + if got != "users.id DESC" { + t.Fatalf("默认排序应为 id DESC,得到 %q", got) + } + if adminUserOrderSQL("unknown", "drop table") != "users.id DESC" { + t.Fatalf("非法 sort/dir 必须回落到 id DESC") + } +} + +func TestAdminUserOrderSQLWhitelist(t *testing.T) { + cases := []struct { + sort, dir, wantSub string + asc bool + }{ + {"id", "asc", "users.id ASC", true}, + {"points", "desc", "users.points DESC, users.id DESC", false}, + {"status", "ASC", "users.banned ASC, users.id DESC", true}, + {"created_at", "", "users.created_at DESC, users.id DESC", false}, + {"online", "asc", "users.last_seen_at ASC NULLS LAST", true}, + {"last_login", "desc", "FROM login_logs", false}, + {"role", "asc", "WHEN 'owner' THEN 100", true}, + {"counts", "desc", "posts.status = '" + model.ContentStatusPublished + "'", false}, + } + for _, tc := range cases { + got := adminUserOrderSQL(tc.sort, tc.dir) + if !strings.Contains(got, tc.wantSub) { + t.Fatalf("sort=%s dir=%s 应包含 %q,得到 %q", tc.sort, tc.dir, tc.wantSub, got) + } + if strings.ContainsAny(tc.sort+tc.dir, ";") { + t.Fatalf("用例本身不应含注入字符") + } + if tc.asc && !strings.Contains(got, "ASC") { + t.Fatalf("sort=%s 应为 ASC:%q", tc.sort, got) + } + } +} diff --git a/backend/service/auth.go b/backend/service/auth.go index 90b72df..2d3695b 100644 --- a/backend/service/auth.go +++ b/backend/service/auth.go @@ -98,12 +98,21 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e return nil, err } + var n int64 + if err := s.db.Model(&model.User{}).Count(&n).Error; err != nil { + return nil, err + } + role := model.RoleUser + if n == 0 { + role = model.RoleOwner + } + user := &model.User{ Username: username, Email: email, Password: string(hashed), Nickname: username, - Role: model.RoleUser, + Role: role, } if err := s.db.Create(user).Error; err != nil { return nil, err diff --git a/backend/service/bg.go b/backend/service/bg.go new file mode 100644 index 0000000..0909bcd --- /dev/null +++ b/backend/service/bg.go @@ -0,0 +1,167 @@ +package service + +import ( + "errors" + "path/filepath" + "regexp" + "strings" +) + +// 站点背景图(前台 / 后台分轨) +const ( + SettingKeyBgSiteURL = "bg_site_url" + SettingKeyBgSiteMode = "bg_site_mode" + SettingKeyBgAdminURL = "bg_admin_url" + SettingKeyBgAdminMode = "bg_admin_mode" + + BgSurfaceSite = "site" + BgSurfaceAdmin = "admin" + + BgModeCover = "cover" + BgModeContain = "contain" + BgModeRepeat = "repeat" + BgModeRepeatX = "repeat-x" + BgModeRepeatY = "repeat-y" + BgModeStretch = "stretch" + BgModeCenter = "center" + DefaultBgMode = BgModeCover +) + +// ErrBgFileMissing 设置了合法 URL 但磁盘上没有对应文件 +var ErrBgFileMissing = errors.New("背景图文件不存在") + +// 仅接受本站上传目录下的 32 位 hex 文件名,禁止外链与路径穿越(写入 CSS url()) +var bgUploadURLRe = regexp.MustCompile(`^/uploads/backgrounds/[0-9a-f]{32}\.(jpg|jpeg|png|webp)$`) + +var bgModes = map[string]struct{}{ + BgModeCover: {}, + BgModeContain: {}, + BgModeRepeat: {}, + BgModeRepeatX: {}, + BgModeRepeatY: {}, + BgModeStretch: {}, + BgModeCenter: {}, +} + +// NormalizeBgMode 校验铺放模式;空串视为默认 cover +func NormalizeBgMode(raw string) (string, bool) { + m := strings.ToLower(strings.TrimSpace(raw)) + if m == "" { + return DefaultBgMode, true + } + if _, ok := bgModes[m]; !ok { + return "", false + } + return m, true +} + +// NormalizeBgURL 校验背景图 URL;空串表示清除(走默认画布) +func NormalizeBgURL(raw string) (string, bool) { + u := strings.TrimSpace(raw) + if u == "" { + return "", true + } + u = strings.ToLower(u) + if !bgUploadURLRe.MatchString(u) { + return "", false + } + return u, true +} + +// NormalizeBgSurface 前台 site / 后台 admin +func NormalizeBgSurface(raw string) (string, bool) { + s := strings.ToLower(strings.TrimSpace(raw)) + if s == BgSurfaceSite || s == BgSurfaceAdmin { + return s, true + } + return "", false +} + +func (s *SettingService) BgSiteURL() (string, error) { + return s.bgURL(SettingKeyBgSiteURL) +} + +func (s *SettingService) BgAdminURL() (string, error) { + return s.bgURL(SettingKeyBgAdminURL) +} + +func (s *SettingService) BgSiteMode() (string, error) { + return s.bgMode(SettingKeyBgSiteMode) +} + +func (s *SettingService) BgAdminMode() (string, error) { + return s.bgMode(SettingKeyBgAdminMode) +} + +func (s *SettingService) bgURL(key string) (string, error) { + v, found, err := s.getValue(key) + if err != nil || !found { + return "", err + } + u, ok := NormalizeBgURL(v) + if !ok { + return "", nil + } + return u, nil +} + +func (s *SettingService) bgMode(key string) (string, error) { + v, found, err := s.getValue(key) + if err != nil || !found { + return DefaultBgMode, err + } + m, ok := NormalizeBgMode(v) + if !ok { + return DefaultBgMode, nil + } + return m, nil +} + +// SetBgSiteURL 保存前台背景;空串删除键 +func (s *SettingService) SetBgSiteURL(url string) error { + return s.setBgURL(SettingKeyBgSiteURL, url) +} + +// SetBgAdminURL 保存后台背景;空串删除键 +func (s *SettingService) SetBgAdminURL(url string) error { + return s.setBgURL(SettingKeyBgAdminURL, url) +} + +func (s *SettingService) setBgURL(key, url string) error { + u, ok := NormalizeBgURL(url) + if !ok { + return ErrInvalidSiteSetting + } + if u == "" { + return s.deleteKey(key) + } + return s.putValue(key, u) +} + +func (s *SettingService) SetBgSiteMode(mode string) error { + return s.setBgMode(SettingKeyBgSiteMode, mode) +} + +func (s *SettingService) SetBgAdminMode(mode string) error { + return s.setBgMode(SettingKeyBgAdminMode, mode) +} + +func (s *SettingService) setBgMode(key, mode string) error { + m, ok := NormalizeBgMode(mode) + if !ok { + return ErrInvalidSiteSetting + } + if m == DefaultBgMode { + return s.deleteKey(key) + } + return s.putValue(key, m) +} + +// BgRelPath 把合法 URL 转成 uploads 根下的相对路径;非法返回空 +func BgRelPath(url string) string { + u, ok := NormalizeBgURL(url) + if !ok || u == "" { + return "" + } + return filepath.FromSlash(strings.TrimPrefix(u, "/uploads/")) +} diff --git a/backend/service/bg_test.go b/backend/service/bg_test.go new file mode 100644 index 0000000..6d8dd54 --- /dev/null +++ b/backend/service/bg_test.go @@ -0,0 +1,56 @@ +package service + +import "testing" + +func TestNormalizeBgURL(t *testing.T) { + okEmpty, ok := NormalizeBgURL(" ") + if !ok || okEmpty != "" { + t.Fatalf("empty should clear, got %q ok=%v", okEmpty, ok) + } + good := "/uploads/backgrounds/0123456789abcdef0123456789abcdef.webp" + got, ok := NormalizeBgURL(good) + if !ok || got != good { + t.Fatalf("want %s, got %q ok=%v", good, got, ok) + } + rejects := []string{ + "http://evil.test/x.webp", + "/uploads/backgrounds/../avatars/x.webp", + "/uploads/images/0123456789abcdef0123456789abcdef.webp", + "/uploads/backgrounds/0123456789abcdef0123456789abcdef.gif", + `/uploads/backgrounds/0123456789abcdef0123456789abcdef.webp")`, + "/uploads/backgrounds/short.webp", + "javascript:alert(1)", + } + for _, u := range rejects { + if _, ok := NormalizeBgURL(u); ok { + t.Fatalf("should reject %q", u) + } + } +} + +func TestNormalizeBgMode(t *testing.T) { + m, ok := NormalizeBgMode("") + if !ok || m != DefaultBgMode { + t.Fatalf("empty mode want cover, got %q ok=%v", m, ok) + } + if _, ok := NormalizeBgMode("zoom"); ok { + t.Fatal("unknown mode should fail") + } + for _, want := range []string{ + BgModeCover, BgModeContain, BgModeRepeat, BgModeRepeatX, BgModeRepeatY, BgModeStretch, BgModeCenter, + } { + got, ok := NormalizeBgMode(" " + want + " ") + if !ok || got != want { + t.Fatalf("mode %s: got %q ok=%v", want, got, ok) + } + } +} + +func TestNormalizeBgSurface(t *testing.T) { + if s, ok := NormalizeBgSurface("SITE"); !ok || s != BgSurfaceSite { + t.Fatalf("site: %q %v", s, ok) + } + if _, ok := NormalizeBgSurface("frontend"); ok { + t.Fatal("frontend is not a surface") + } +} diff --git a/backend/service/custom_code_settings_test.go b/backend/service/custom_code_settings_test.go deleted file mode 100644 index 2b42991..0000000 --- a/backend/service/custom_code_settings_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package service - -import "testing" - -func TestSanitizeCustomSnippet(t *testing.T) { - got, err := sanitizeCustomSnippet(" body{color:red} ", "b", "b", " maxRunes { - return "", ErrInvalidSiteSetting - } - if closer != "" && strings.Contains(strings.ToLower(s), closer) { - return "", ErrInvalidSiteSetting - } - return s, nil -} - -// CustomCSS 全站自定义 CSS。缺行/空=无。 -func (s *SettingService) CustomCSS() (string, error) { - v, found, err := s.getValue(SettingKeyCustomCSS) - if err != nil || !found { - return "", err - } - out, nerr := sanitizeCustomSnippet(v, " BackgroundMaxBytes { + _ = os.Remove(tmp) + return "", tooLarge() + } + + rf, err := os.Open(tmp) + if err != nil { + _ = os.Remove(tmp) + return "", err + } + w, h, err := decodeImageSizeReader(rf, format.mime) + _ = rf.Close() + if err != nil { + _ = os.Remove(tmp) + return "", err + } + if w < 1 || h < 1 { + _ = os.Remove(tmp) + return "", errors.New("无效的图片尺寸") + } + if w > BackgroundMaxDim || h > BackgroundMaxDim { + _ = os.Remove(tmp) + return "", errors.New("背景图边长不能超过 8192px") + } + if err := os.Rename(tmp, fullPath); err != nil { + _ = os.Remove(tmp) + return "", err + } + return "/uploads/backgrounds/" + filename, nil +} + +// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内 +func (s *UploadService) BackgroundFileExists(url string) bool { + rel := BgRelPath(url) + if rel == "" { + return false + } + abs := filepath.Join(s.dir, rel) + info, err := os.Stat(abs) + return err == nil && !info.IsDir() +} + +// RemoveBackgroundIfUnused 旧墙纸不再被前台或后台引用时删除物理文件 +func (s *UploadService) RemoveBackgroundIfUnused(oldURL, siteURL, adminURL string) { + if oldURL == "" || oldURL == siteURL || oldURL == adminURL { + return + } + rel := BgRelPath(oldURL) + if rel == "" { + return + } + abs := filepath.Join(s.dir, rel) + if err := os.Remove(abs); err != nil && !os.IsNotExist(err) { + log.Printf("[upload] 删除背景图失败 path=%s: %v", abs, err) + } +} + // UseAvatar 选用一张【本人历史上传】的头像 func (s *UploadService) UseAvatar(userID uint, url string) error { url = strings.TrimSpace(url) diff --git a/backend/version/version.go b/backend/version/version.go new file mode 100644 index 0000000..80d1f45 --- /dev/null +++ b/backend/version/version.go @@ -0,0 +1,9 @@ +// Package version 由构建注入发布号;本地 go run 为 dev。 +package version + +var ( + // Version 与仓库根 VERSION 对齐,Docker / ldflags 写入。 + Version = "dev" + // Commit 短 sha,未知则为 unknown。 + Commit = "unknown" +) diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..2475af4 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,25 @@ +# 官方运行时环境变量。复制为 .env 后填写,不要提交 .env。 +# 契约见 CONTRACT.md。反代由宿主机 Nginx 等承担,见 nginx.example.conf。 + +JIANG13_VERSION=0.1.0 + +# 镜像名。未推仓库时 docker compose up --build 会打到这些本地 tag。 +# 发布后改为 registry.example.com/jiang13-bbs-api 等即可 docker compose pull。 +JIANG13_IMAGE_API=jiang13-bbs-api +JIANG13_IMAGE_WEB=jiang13-bbs-web + +# 对外 origin,须与浏览器地址栏一致(含 https、无尾斜杠)。CORS 兜底。 +SITE_URL=https://bbs.example.com + +# 发布到宿主机的地址/端口,供本机 Nginx 反代。默认只绑 loopback。 +JIANG13_BIND=127.0.0.1 +JIANG13_WEB_PORT=3000 +JIANG13_API_PORT=3001 + +# Postgres(官方 Compose 不把 5432 打到宿主机) +POSTGRES_USER=jiang13 +POSTGRES_PASSWORD=change-me +POSTGRES_DB=jiang13 + +# 生产必须显式设置。留空则首次启动写入 data/.jwt_secret(依赖 appdata volume)。 +JWT_SECRET=change-me-to-a-long-random-string diff --git a/deploy/CONTRACT.md b/deploy/CONTRACT.md new file mode 100644 index 0000000..8af1743 --- /dev/null +++ b/deploy/CONTRACT.md @@ -0,0 +1,90 @@ +# 官方运行时契约 + +变更本文件中的服务名、端口、volume、环境变量,视为**运行时大版本**,须在 changelog 写明,并给出最低可升级的应用版本。不要为了省事改本地开发的 `start.bat` / 仓库根 `docker-compose.yml`。 + +当前应用版本见仓库根 `VERSION`(现 `0.1.0`)。最低可升级版本:无(首个官方运行时)。 + +## 与本地开发的边界 + +| | 本地开发 | 官方运行时 | +|--|----------|------------| +| Compose | 仓库根 `docker-compose.yml`,只起 Postgres,映射 `5432` | `deploy/docker-compose.yml`,项目名 `jiang13` | +| 后端 | `cd backend && go run ./cmd/jiang13`,数据 `backend/data/` | 容器 `api`,`JIANG13_WORK_PATH=/var/lib/jiang13` | +| 前端 | `cd frontend && npm run dev`,`:3000` | 容器 `web`,Next standalone `:3000` | +| 入口 | 浏览器 `http://localhost:3000` | 宿主机反代(Nginx 等)同源反代 `127.0.0.1:3000` + `127.0.0.1:3001` | + +两套 Postgres volume 名字空间不同,可以并存,互不覆盖。官方 Compose **不包含**反代容器。 + +## 服务名(Compose) + +- `postgres` — PostgreSQL 17(不映射宿主机 5432) +- `api` — Go,容器内 `3001`,默认发布 `127.0.0.1:3001` +- `web` — Next.js standalone,容器内 `3000`,默认发布 `127.0.0.1:3000` + +禁止把 API 单独做到另一个子域。`/api` 与 `/api/ws` 必须由反代直达 `api`,不能进 Next(Upgrade 无法经 Next rewrite)。 + +## Volume + +| 名 | 容器路径 | 内容 | +|----|----------|------| +| `pgdata` | `/var/lib/postgresql/data` | 数据库 | +| `appdata` | `/var/lib/jiang13/data` | 上传、`private/` 附件、`.jwt_secret` | + +镜像内不放真实 `app.ini`、不放用户数据。 + +## 环境变量 + +### api + +| 变量 | 必需 | 说明 | +|------|------|------| +| `HTTP_PORT` | 否 | 默认 `3001` | +| `DEV_MODE` | 是(Compose 写死 `false`) | 生产 cookie 走 `__Host-`,须反代 HTTPS | +| `JIANG13_WORK_PATH` | 是 | 固定 `/var/lib/jiang13` | +| `DATA_DIR` | 是 | 固定 `/var/lib/jiang13/data` | +| `DB_DSN` | 是 | 指向服务名 `postgres` | +| `JWT_SECRET` | 是 | 强随机;勿依赖自动生成做多副本 | +| `SITE_URL` | 建议 | 对外 origin,无尾斜杠,如 `https://bbs.example.com` | +| `CORS_ORIGINS` | 否 | 额外 origin,逗号分隔 | + +### web + +| 变量 | 必需 | 说明 | +|------|------|------| +| `BACKEND_URL` | 是 | 固定 `http://api:3001`(SSR / middleware,勿写成公网 URL) | +| `PORT` / `HOSTNAME` | 否 | `3000` / `0.0.0.0` | +| `NEXT_PUBLIC_APP_VERSION` | 构建期 | 与 `VERSION` 相同;**不要**设置 `NEXT_PUBLIC_API_URL=http://api:3001`(会泄漏到浏览器) | + +浏览器 API 走同源 `/api/*`(由宿主机反代直达 Go)。WebSocket 走同源 `/api/ws`,不要设 `NEXT_PUBLIC_WS_URL`。 + +### 宿主机发布 + +| 变量 | 默认 | 说明 | +|------|------|------| +| `JIANG13_BIND` | `127.0.0.1` | 只给本机反代;需要对外直连再改 | +| `JIANG13_WEB_PORT` | `3000` | web 宿主机端口 | +| `JIANG13_API_PORT` | `3001` | api 宿主机端口 | + +### 镜像 tag + +`JIANG13_VERSION` 同时作为 `api`/`web` 镜像 tag。`JIANG13_IMAGE_API` / `JIANG13_IMAGE_WEB` 默认为本地名;推仓库后改成 registry 路径即可 `docker compose pull`。 + +`POSTGRES_PASSWORD` 会拼进 `DB_DSN`,不要包含 `@ : / # ?` 等 URL 保留字符。 + +## 健康检查 + +- `api`:`GET http://127.0.0.1:3001/health`(含 DB ping、`version`) +- `web`:`GET http://127.0.0.1:3000/healthz`(不访问后端) + +## 更新 + +应用升级:换同一契约下的新镜像 tag,然后: + +``` +docker compose pull +docker compose up -d +``` + +新 `api` 启动时执行既有 `AutoMigrate`。接受短暂停机。上线前不为双版本并存写兼容垫片。 + +改 Compose / 环境变量 / volume 路径属于运行时升级,不能只 pull。 diff --git a/deploy/Dockerfile.api b/deploy/Dockerfile.api new file mode 100644 index 0000000..b144a31 --- /dev/null +++ b/deploy/Dockerfile.api @@ -0,0 +1,32 @@ +# 构建上下文:仓库根。不含源码与工具链。 +# syntax=docker/dockerfile:1 + +FROM golang:1.27-bookworm AS build +WORKDIR /src +ENV GOTOOLCHAIN=local +COPY backend/go.mod backend/go.sum ./ +RUN go mod download +COPY backend/ ./ +ARG VERSION=dev +ARG COMMIT=unknown +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w -X github.com/freefire/jiang13-bbs/version.Version=${VERSION} -X github.com/freefire/jiang13-bbs/version.Commit=${COMMIT}" \ + -o /out/jiang13 ./cmd/jiang13 + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata wget su-exec \ + && adduser -D -H -u 1000 jiang13 \ + && mkdir -p /var/lib/jiang13/data +COPY --from=build /out/jiang13 /usr/local/bin/jiang13 +COPY deploy/api-entrypoint.sh /usr/local/bin/api-entrypoint.sh +RUN sed -i 's/\r$//' /usr/local/bin/api-entrypoint.sh && chmod +x /usr/local/bin/api-entrypoint.sh +ENV JIANG13_WORK_PATH=/var/lib/jiang13 \ + HTTP_PORT=3001 \ + DEV_MODE=false \ + TZ=UTC +WORKDIR /var/lib/jiang13 +EXPOSE 3001 +HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \ + CMD wget -qO- http://127.0.0.1:3001/health || exit 1 +ENTRYPOINT ["/usr/local/bin/api-entrypoint.sh"] diff --git a/deploy/Dockerfile.web b/deploy/Dockerfile.web new file mode 100644 index 0000000..4cacc42 --- /dev/null +++ b/deploy/Dockerfile.web @@ -0,0 +1,37 @@ +# 构建上下文:frontend/。standalone 产物,运行时不含完整 node_modules。 +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +FROM node:22-bookworm-slim AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN mkdir -p public +ARG VERSION=dev +ARG BACKEND_URL=http://api:3001 +ENV NEXT_TELEMETRY_DISABLED=1 \ + NODE_ENV=production \ + NEXT_PUBLIC_APP_VERSION=${VERSION} \ + BACKEND_URL=${BACKEND_URL} +RUN npm run build + +FROM node:22-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production \ + PORT=3000 \ + HOSTNAME=0.0.0.0 \ + NEXT_TELEMETRY_DISABLED=1 \ + BACKEND_URL=http://api:3001 +RUN mkdir -p public .next && chown -R node:node /app +COPY --from=builder --chown=node:node /app/public ./public +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static +USER node +EXPOSE 3000 +HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \ + CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "server.js"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..a336cc0 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,74 @@ +# 官方运行时(Docker Compose) + +一体交付:Next.js SSR + Go API + PostgreSQL 17。 **不含反代**:TLS 与同源路由由你现有的 Nginx / Caddy 处理,示例见 [nginx.example.conf](./nginx.example.conf)。契约见 [CONTRACT.md](./CONTRACT.md)。 + +本地开发**不要**用这份 Compose。请继续用仓库根 `start.bat` / `start.sh`(只起 Postgres,前后端热重载)。 + +## 首次安装 + +1. 安装 Docker Compose v2。 +2. 复制环境文件并改密码: + +```bash +cd deploy +cp .env.example .env +# 编辑 .env:POSTGRES_PASSWORD、JWT_SECRET、SITE_URL +``` + +`SITE_URL` 填浏览器看到的 HTTPS origin(如 `https://bbs.example.com`)。`DEV_MODE=false` 会启用 `__Host-` cookie,**必须由你的反代提供 HTTPS**。 + +3. 启动(尚未推镜像仓库时从源码构建): + +```bash +docker compose up -d --build +``` + +默认只把 `127.0.0.1:3000`(web)和 `127.0.0.1:3001`(api)交给本机反代。与本地 `start.bat` 的 3000/3001 冲突时,先停开发进程,或改 `.env` 里的 `JIANG13_WEB_PORT` / `JIANG13_API_PORT`。 + +4. 按 [nginx.example.conf](./nginx.example.conf) 把同一 Host 指过来: + +- `/` → web `:3000` +- `/api/`(含 `/api/ws`)、`/uploads/`、`/health` → api `:3001` + +`GET https://你的域名/health` 应返回 `"status":"ok"` 和 `version`。空库首次注册的账号自动成为站长。不要把 API 放到另一个子域。 + +数据在 Docker volume `appdata` / `pgdata`。不要把 `backend/data` 或真实 `app.ini` 打进镜像。 + +## 更新应用(官方路径) + +镜像已推到仓库、且 `.env` 里 `JIANG13_IMAGE_*` 指向该仓库时: + +```bash +cd deploy +docker compose pull +docker compose up -d +``` + +只换应用 tag,不改本目录 Compose / 环境变量表。启动时自动迁移。建议先备份: + +```bash +docker compose exec postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > backup.sql +``` + +`appdata` 用 `docker volume` 备份。失败则把 `JIANG13_VERSION` 改回上一版再 `up -d`。 + +尚未发布镜像时,在更新源码后执行 `docker compose up -d --build`。 + +Compose 文件本身有变更时,按新版本 changelog **合并** `deploy/docker-compose.yml` 与 `.env`,不能只 pull。 + +## 停止 + +```bash +cd deploy +docker compose down +``` + +加 `-v` 会删数据库和上传,仅在你确认要清空时使用。不要对仓库根的开发 Postgres 执行这条。 + +## 不做本期 + +- 后台一键更新 +- 预编译包 / watchdog +- 把 Docker socket 交给 Web 进程 +- 在 Compose 里内置 Nginx / Caddy +- Cloudflare Workers 作为官方运行时 diff --git a/deploy/api-entrypoint.sh b/deploy/api-entrypoint.sh new file mode 100644 index 0000000..b2580e6 --- /dev/null +++ b/deploy/api-entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# 以 root 校正 volume 属主后降权运行。数据目录契约:/var/lib/jiang13/data +set -e +mkdir -p /var/lib/jiang13/data/uploads /var/lib/jiang13/data/private +chown -R jiang13:jiang13 /var/lib/jiang13 +exec su-exec jiang13 /usr/local/bin/jiang13 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..a54c5c8 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,72 @@ +# 官方运行时契约(变更视为运行时大版本,须写 changelog) +# +# 本地开发不使用本文件:仓库根 docker-compose.yml 只起 Postgres, +# 配合 start.bat / cd backend && go run ./cmd/jiang13 + frontend npm run dev。 +# 本 Compose 不含反代:页面 :3000、API :3001 交给宿主机 Nginx / Caddy。 + +name: jiang13 + +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-jiang13} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env} + POSTGRES_DB: ${POSTGRES_DB:-jiang13} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-jiang13} -d ${POSTGRES_DB:-jiang13}"] + interval: 5s + timeout: 5s + retries: 5 + + api: + image: ${JIANG13_IMAGE_API:-jiang13-bbs-api}:${JIANG13_VERSION:-0.1.0} + build: + context: .. + dockerfile: deploy/Dockerfile.api + args: + VERSION: ${JIANG13_VERSION:-0.1.0} + COMMIT: ${JIANG13_COMMIT:-unknown} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + HTTP_PORT: "3001" + DEV_MODE: "false" + JIANG13_WORK_PATH: /var/lib/jiang13 + DATA_DIR: /var/lib/jiang13/data + DB_DSN: postgres://${POSTGRES_USER:-jiang13}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-jiang13}?sslmode=disable + JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in deploy/.env} + SITE_URL: ${SITE_URL:-https://localhost} + volumes: + - appdata:/var/lib/jiang13/data + ports: + - "${JIANG13_BIND:-127.0.0.1}:${JIANG13_API_PORT:-3001}:3001" + + web: + image: ${JIANG13_IMAGE_WEB:-jiang13-bbs-web}:${JIANG13_VERSION:-0.1.0} + build: + context: ../frontend + dockerfile: ../deploy/Dockerfile.web + args: + VERSION: ${JIANG13_VERSION:-0.1.0} + BACKEND_URL: http://api:3001 + restart: unless-stopped + depends_on: + api: + condition: service_healthy + environment: + BACKEND_URL: http://api:3001 + NODE_ENV: production + PORT: "3000" + HOSTNAME: 0.0.0.0 + ports: + - "${JIANG13_BIND:-127.0.0.1}:${JIANG13_WEB_PORT:-3000}:3000" + +volumes: + pgdata: + appdata: diff --git a/deploy/nginx.example.conf b/deploy/nginx.example.conf new file mode 100644 index 0000000..00d8a17 --- /dev/null +++ b/deploy/nginx.example.conf @@ -0,0 +1,49 @@ +# 宿主机 Nginx 示例(官方 Compose 不含反代)。 +# 页面、/api、/api/ws、/uploads 必须同一 Host;/api/ws 直达 Go,不要进 Next。 +# 把 bbs.example.com 与证书路径换成实际值,upstream 端口与 .env 里 JIANG13_*_PORT 一致。 + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 443 ssl http2; + server_name bbs.example.com; + + # ssl_certificate /path/to/fullchain.pem; + # ssl_certificate_key /path/to/privkey.pem; + + client_max_body_size 512m; + + location /api/ { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + } + + location /uploads/ { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /health { + proxy_pass http://127.0.0.1:3001; + proxy_set_header Host $host; + } + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..846075c --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +.env +.env.local +.env.*.local +*.md +coverage diff --git a/frontend/.env.example b/frontend/.env.example index d5d6695..febaadc 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -9,3 +9,6 @@ NEXT_PUBLIC_API_URL=http://localhost:3001 # 部署到 Cloudflare Workers 等边缘环境时【必须】显式配置为后端 HTTPS 地址, # 边缘运行时不存在 localhost,留空会导致 SSR 登录态轮转直接放行失效。 # BACKEND_URL=https://your-backend.example.com + +# 可选。官方 Docker 构建会写入与仓库根 VERSION 相同的值;本地 dev 可不设。 +# NEXT_PUBLIC_APP_VERSION=0.1.0 diff --git a/frontend/app/about/page.tsx b/frontend/app/about/page.tsx index 07d5907..aa67173 100644 --- a/frontend/app/about/page.tsx +++ b/frontend/app/about/page.tsx @@ -96,61 +96,63 @@ export default async function AboutIndexPage() { - } mobileExtras={ announcements.length > 0 ? : null } > -
-
-

- - 关于本站 -

-

- {error - ? "加载失败" - : pages.length > 0 - ? `共 ${pages.length} 篇站点文档` - : "站点说明与政策文档"} -

-
- {canManagePages ? ( - - 管理 - - ) : null} -
+
+
+ +
+
+

+ + 关于本站 +

+

+ {error + ? "加载失败" + : pages.length > 0 + ? `共 ${pages.length} 篇站点文档` + : "站点说明与政策文档"} +

+
+ {canManagePages ? ( + + 管理 + + ) : null} +
+
- {error ? ( -

{error}

- ) : pages.length === 0 ? ( -
-

- 暂无介绍 -

-

有新文档时会显示在这里

-
- ) : ( -
- {pages.map((p, i) => ( - 0} /> - ))} -
- )} + {error ? ( +

{error}

+ ) : pages.length === 0 ? ( +
+

+ 暂无介绍 +

+

有新文档时会显示在这里

+
+ ) : ( + pages.map((p, i) => 0} />) + )} +
); } diff --git a/frontend/app/admin/appearance/AppearanceAdmin.tsx b/frontend/app/admin/appearance/AppearanceAdmin.tsx index d7c0fe7..6724091 100644 --- a/frontend/app/admin/appearance/AppearanceAdmin.tsx +++ b/frontend/app/admin/appearance/AppearanceAdmin.tsx @@ -12,7 +12,11 @@ import { RotateCcw, Pipette, Sparkles, - FileCode2, + ImageIcon, + Monitor, + LayoutDashboard, + Trash2, + Upload, } from "lucide-react"; import { AdminPageHeader, @@ -20,8 +24,13 @@ import { AdminSettingRow, AdminStepper, AdminSwitch, + AdminSegmented, } from "@/components/admin"; -import { apiUpdateSiteSettings, type PublicSettings, type UpdateSiteSettingsBody } from "@/lib/api"; +import { + apiUpdateSiteSettings, + apiUploadSiteBackground, + type PublicSettings, +} from "@/lib/api"; import { toast } from "@/lib/toast"; import { ACCENT_PRESETS, @@ -32,26 +41,23 @@ import { normalizeHex, } from "@/lib/theme"; import { - MAX_CUSTOM_CSS_CHARS, - MAX_CUSTOM_JS_CHARS, - SAMPLE_CUSTOM_CSS, - SAMPLE_CUSTOM_JS, - applyCustomCSS, - clipCustomSnippet, -} from "@/lib/customCode"; + BG_MODE_OPTIONS, + applySiteBackground, + bgConfigFromSettings, + normalizeBgMode, + previewBoxStyle, + type BgMode, + type BgSurface, + type SiteBgConfig, +} from "@/lib/bg"; -// 后台「外观设置」:主题色、自定义 CSS/JS、代码块折叠与界面动画。 -// 主题色 / 自定义 CSS 修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。 -// 自定义 JS 不在编辑时执行,保存后整页加载生效。 +// 后台「外观设置」:主题色、代码块折叠与界面动画。 +// 主题色修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。 export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) { const router = useRouter(); const initialAccent = initial.accent || ""; const [savedHex, setSavedHex] = useState(initialAccent); const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT); - const [savedCustomCSS, setSavedCustomCSS] = useState(initial.custom_css || ""); - const [customCSS, setCustomCSS] = useState(initial.custom_css || SAMPLE_CUSTOM_CSS); - const [savedCustomJS, setSavedCustomJS] = useState(initial.custom_js || ""); - const [customJS, setCustomJS] = useState(initial.custom_js || SAMPLE_CUSTOM_JS); const [codeAutoFold, setCodeAutoFold] = useState(initial.code_block_auto_fold !== false); const [foldLines, setFoldLines] = useState( Number.isFinite(initial.code_block_fold_lines) ? initial.code_block_fold_lines : 5 @@ -66,10 +72,12 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } const [animChrome, setAnimChrome] = useState(initial.anim_chrome !== false); const [togglingMotion, setTogglingMotion] = useState(false); const [saving, setSaving] = useState(false); + const [bg, setBg] = useState(() => bgConfigFromSettings(initial)); + const [bgBusy, setBgBusy] = useState(null); + const siteFileRef = useRef(null); + const adminFileRef = useRef(null); const savedRef = useRef(savedHex); savedRef.current = savedHex; - const savedCSSRef = useRef(savedCustomCSS); - savedCSSRef.current = savedCustomCSS; const normalized = useMemo(() => normalizeHex(draft), [draft]); const pair = useMemo( @@ -78,12 +86,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } ); const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]); const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : ""; - const accentDirty = (savedHex || DEFAULT_ACCENT) !== (normalized || ""); - const cssIsGuide = !savedCustomCSS && customCSS === SAMPLE_CUSTOM_CSS; - const jsIsGuide = !savedCustomJS && customJS === SAMPLE_CUSTOM_JS; - const cssDirty = customCSS !== savedCustomCSS && !cssIsGuide; - const jsDirty = customJS !== savedCustomJS && !jsIsGuide; - const dirty = accentDirty || cssDirty || jsDirty; + const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || ""); const invalid = draft.trim() !== "" && !normalized; useEffect(() => { @@ -93,35 +96,19 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } }; }, [normalized]); - useEffect(() => { - applyCustomCSS(customCSS, true); - return () => { - applyCustomCSS(savedCSSRef.current, false); - }; - }, [customCSS]); - const handleSave = async () => { if (!normalized || saving) return; - const body: UpdateSiteSettingsBody = {}; - if (accentDirty) body.accent = submitHex; - if (cssDirty) body.custom_css = customCSS; - if (jsDirty) body.custom_js = customJS; - if (Object.keys(body).length === 0) return; setSaving(true); try { - const res = await apiUpdateSiteSettings(body); + const res = await apiUpdateSiteSettings({ accent: submitHex }); const next = (res.accent || "").toLowerCase(); setSavedHex(next); - setSavedCustomCSS(res.custom_css || ""); - setSavedCustomJS(res.custom_js || ""); applyAccentTheme(next || null); - applyCustomCSS(res.custom_css || "", false); router.refresh(); - toast(jsDirty ? "外观已保存;自定义 JS 将在整页刷新后执行" : "外观已保存并对全站生效"); + toast(next ? "主题色已保存并对全站生效" : "已恢复默认主题色"); } catch (e) { toast(e instanceof Error ? e.message : "保存失败", "error"); applyAccentTheme(savedRef.current || null); - applyCustomCSS(savedCSSRef.current, false); } finally { setSaving(false); } @@ -129,10 +116,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } const discard = () => { setDraft(savedHex || DEFAULT_ACCENT); - setCustomCSS(savedCustomCSS || SAMPLE_CUSTOM_CSS); - setCustomJS(savedCustomJS || SAMPLE_CUSTOM_JS); applyAccentTheme(savedHex || null); - applyCustomCSS(savedCustomCSS, true); }; const toggleCodeFold = async () => { @@ -202,12 +186,180 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } } }; + const commitBg = (next: SiteBgConfig) => { + setBg(next); + applySiteBackground(next); + }; + + const saveBgPatch = async (patch: Partial, toastOk: string) => { + const res = await apiUpdateSiteSettings(patch); + commitBg(bgConfigFromSettings(res)); + toast(toastOk, "ok"); + }; + + const onPickBg = async (surface: BgSurface, file: File | undefined) => { + if (!file || bgBusy) return; + if (!/^image\/(jpeg|png|webp)$/i.test(file.type) && !/\.(jpe?g|png|webp)$/i.test(file.name)) { + toast("仅支持 JPEG / PNG / WebP", "error"); + return; + } + if (file.size > 8 * 1024 * 1024) { + toast("背景图不能超过 8MB", "error"); + return; + } + setBgBusy(surface); + try { + const pendingMode = surface === "site" ? bg.bg_site_mode : bg.bg_admin_mode; + let res = await apiUploadSiteBackground(surface, file); + let next = bgConfigFromSettings(res); + const savedMode = surface === "site" ? next.bg_site_mode : next.bg_admin_mode; + if (pendingMode !== savedMode) { + const modeKey = surface === "site" ? "bg_site_mode" : "bg_admin_mode"; + res = await apiUpdateSiteSettings({ [modeKey]: pendingMode }); + next = bgConfigFromSettings(res); + } + commitBg(next); + toast(surface === "site" ? "前台背景已更新" : "后台背景已更新", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "上传失败", "error"); + } finally { + setBgBusy(null); + } + }; + + const onChangeBgMode = async (surface: BgSurface, mode: BgMode) => { + if (bgBusy) return; + const key = surface === "site" ? "bg_site_mode" : "bg_admin_mode"; + const url = surface === "site" ? bg.bg_site_url : bg.bg_admin_url; + if (!url) { + commitBg({ ...bg, [key]: mode }); + return; + } + setBgBusy(surface); + try { + await saveBgPatch( + { [key]: mode }, + surface === "site" ? "前台铺放模式已保存" : "后台铺放模式已保存" + ); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败", "error"); + } finally { + setBgBusy(null); + } + }; + + const onClearBg = async (surface: BgSurface) => { + if (bgBusy) return; + const key = surface === "site" ? "bg_site_url" : "bg_admin_url"; + setBgBusy(surface); + try { + await saveBgPatch( + { [key]: "" }, + surface === "site" ? "已恢复前台默认画布" : "已恢复后台默认画布" + ); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败", "error"); + } finally { + setBgBusy(null); + } + }; + + const renderBgRail = ( + surface: BgSurface, + title: string, + desc: string, + Icon: typeof Monitor, + fileRef: typeof siteFileRef + ) => { + const url = surface === "site" ? bg.bg_site_url : bg.bg_admin_url; + const mode = surface === "site" ? bg.bg_site_mode : bg.bg_admin_mode; + const busy = bgBusy === surface; + return ( +
+
+
+

+ {title} +

+

{desc}

+
+
+
+ {!url && ( + + 默认纹饰画布 + + )} +
+ { + const f = e.target.files?.[0]; + e.target.value = ""; + void onPickBg(surface, f); + }} + /> +
+ + {url ? ( + + ) : null} +
+
+

铺放模式

+ void onChangeBgMode(surface, normalizeBgMode(key))} + options={BG_MODE_OPTIONS.map((o) => ({ + key: o.key, + label: o.label, + disabled: busy, + }))} + /> +

+ {BG_MODE_OPTIONS.find((o) => o.key === mode)?.hint} +

+
+
+ ); + }; + return (
@@ -370,7 +522,8 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
-
+
+

板块目录

- 选中的板块卡片 + 选中的板块
-
+
@@ -516,81 +669,29 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } - 自定义代码 + 站点背景图 } - description="注入全站 HTML。空配置时先显示注释示例(不会自动写入);保存后才对访客生效。勿包含 或 。" - className="mb-6" + description="前台与后台可分别上传 JPEG / PNG / WebP(≤8MB)。未配置时使用默认纹饰画布。" > -
-
-
- - {customCSS !== SAMPLE_CUSTOM_CSS && ( - - )} -
-

- 覆盖令牌或组件类。本页即时预览;去掉注释后的规则才会改变外观。上限{" "} - {MAX_CUSTOM_CSS_CHARS.toLocaleString()} 字。 -

-