diff --git a/.cursor/project_memory.md b/.cursor/project_memory.md index 2b3241e..c67f7aa 100644 --- a/.cursor/project_memory.md +++ b/.cursor/project_memory.md @@ -283,6 +283,7 @@ - **全站图片策略:用户上传的图片统一 WebP**。头像裁剪/转码在浏览器端完成(Canvas → toBlob('image/webp')),后端只收 WebP - 存储:`backend/data/uploads/avatars/{随机hex32}.webp`(已在 backend/data/ gitignore 内),Gin `r.Static("/uploads", ...)` 提供静态服务;Next rewrite 新增 `/uploads/:path*` 同源代理到后端(与 /api 一样走 BACKEND_URL 兜底 localhost:3001) +- **本地开发数据目录铁律(2026-09-16)**:`config.Parse` 从 cwd 向上解析 backend 根,不跟 `os.Getwd()` 走。规范启动 `cd backend && go run ./cmd/jiang13`;数据只有 `backend/data/`。在 `cmd/jiang13/` 启动会写出另一套 `cmd/jiang13/data`,DB 里头像 404。规则见 `.cursor/rules/jiang13-bbs-local-dev.mdc`;可用 `JIANG13_WORK_PATH` 覆盖。启动日志打印 `workPath`/`dataDir`。`:3001` 已占用则不要再开第二个后端 - DB:`attachments` 表(user_id+kind 联合索引,kind 取值 `avatar`/`image`,记录 url/mime/size/width/height);`service/upload.go` 校验 RIFF/WEBP 魔数 + `golang.org/x/image/webp` 解码尺寸(正方形 64~512px)+ **大小限制以裁剪后文件为准(≤2MiB,MaxBytesReader 限请求体)**,落盘与 user.avatar 更新在同一事务 - 路由(需登录 + CSRF):`POST /api/upload/avatar`(multipart 字段 file)、`PUT /api/avatar/use`(只能选用本人 attachments 中的 url)、`GET /api/my/media`(本人全部附件,id DESC 限 60 条)、`DELETE /api/my/attachments/:id`(被帖子 content 引用返回 409;是当前头像则事务内置空 user.avatar;成功后物理删除文件) - **头像只能走上传/媒体库选用通道**:`UpdateProfile` 已移除 avatar 字段,禁止通过资料接口写任意外链 URL diff --git a/.cursor/rules/jiang13-bbs-auth-realtime.mdc b/.cursor/rules/jiang13-bbs-auth-realtime.mdc index 6aaf430..0edbc32 100644 --- a/.cursor/rules/jiang13-bbs-auth-realtime.mdc +++ b/.cursor/rules/jiang13-bbs-auth-realtime.mdc @@ -26,4 +26,4 @@ alwaysApply: true - remote:`https://git.iioio.com/freefire/jiang13-bbs.git`,分支 `main` - 自建 Gitea 偶发不稳:`git push` 失败则**原样重试一次**;勿改 remote / 勿反复重试 -- 勿提交 `node_modules`、`.next`、`backend/cmd/jiang13/data`(含 `.jwt_secret`)、真实 `app.ini` +- 勿提交 `node_modules`、`.next`、`backend/data`、`backend/cmd/jiang13/data`(错误 cwd 遗留)、真实 `app.ini` diff --git a/.cursor/rules/jiang13-bbs-engineering.mdc b/.cursor/rules/jiang13-bbs-engineering.mdc index 34e558c..ee12636 100644 --- a/.cursor/rules/jiang13-bbs-engineering.mdc +++ b/.cursor/rules/jiang13-bbs-engineering.mdc @@ -22,6 +22,7 @@ alwaysApply: true - Avatar 所有调用必须 `src={xxx.avatar || undefined}` - CSRF cookie 是 URLEncoding(含 `=`),解析勿用 `split('=')[1]` - PowerShell 5.1 中文脚本需 UTF-8 BOM;npx/npm 用 `npx.cmd`/`npm.cmd` +- 后端必须 `cd backend && go run ./cmd/jiang13`;cwd 错了会把头像写到 `cmd/jiang13/data`,规范目录只有 `backend/data`(详见 `jiang13-bbs-local-dev`) ## 用户偏好(自 Trae user_profile) diff --git a/.cursor/rules/jiang13-bbs-local-dev.mdc b/.cursor/rules/jiang13-bbs-local-dev.mdc new file mode 100644 index 0000000..2adac2e --- /dev/null +++ b/.cursor/rules/jiang13-bbs-local-dev.mdc @@ -0,0 +1,35 @@ +--- +description: 本地开发启动约定:后端工作目录、唯一数据目录、单实例,避免头像/上传 404 +alwaysApply: true +--- + +# 本地开发环境 + +## 后端启动(必须遵守) + +规范命令与 `start.bat` / `start.sh` 一致: + +``` +cd backend +go run ./cmd/jiang13 +``` + +- 数据目录**只有** `backend/data/`(头像在 `backend/data/uploads/avatars/`) +- 配置文件是 `backend/app.ini`,不是仓库根、也不是 `cmd/jiang13/` +- **禁止**在仓库根、`backend/cmd/jiang13/` 或其他目录执行 `go run`(会另建一套 data,DB 里头像 URL 对不上文件) +- **禁止**同时跑两个后端。启动前若 `:3001` 已在听,沿用现有进程,或先停再启;不要再开一个 +- 启动日志必须含 `workPath=.../backend` 且 `dataDir=.../backend/data`。若不是,立刻停掉重来 +- 覆盖工作目录只用环境变量 `JIANG13_WORK_PATH`(指向 `backend/`) + +## 前端 + +``` +cd frontend +npm.cmd run dev +``` + +`:3000` 把 `/uploads` rewrite 到 `:3001`。头像 404 先查后端 `dataDir`,不要改前端。 + +## 遗留目录 + +`backend/cmd/jiang13/data/` 是错误 cwd 的产物,禁止当数据源。发现后把 `uploads/` 合并进 `backend/data/uploads/`,再删遗留目录。 diff --git a/README.md b/README.md index e4c2b65..bfd98f2 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,9 @@ go mod download go run ./cmd/jiang13 ``` -后端默认监听 `:3001`,首次启动自动执行数据库迁移并写入默认板块。 +**工作目录必须是 `backend/`。** 上传文件与 JWT 密钥落在 `backend/data/`(头像 `backend/data/uploads/avatars/`)。不要在仓库根或 `backend/cmd/jiang13/` 执行 `go run`,否则会另建一套 data,数据库里头像 URL 会对不上文件。启动日志应含 `workPath=.../backend` 和 `dataDir=.../backend/data`。需要覆盖时设置 `JIANG13_WORK_PATH` 指向 `backend/`。 + +后端默认监听 `:3001`,首次启动自动执行数据库迁移并写入默认板块。同一时间只跑一个后端进程。 ### 3. 启动前端 @@ -160,7 +162,7 @@ DATA = data DEV_MODE = true ``` -环境变量优先级高于 app.ini:`HTTP_PORT`、`DB_DSN`、`JWT_SECRET`、`DEV_MODE`。 +环境变量优先级高于 app.ini:`HTTP_PORT`、`DB_DSN`、`JWT_SECRET`、`DEV_MODE`。`JIANG13_WORK_PATH` 可强制指定后端根目录(`app.ini` 与 `data/` 所在处)。 ### 前端(`frontend/.env.local`) diff --git a/backend/cmd/jiang13/main.go b/backend/cmd/jiang13/main.go index 2fac6e2..521685a 100644 --- a/backend/cmd/jiang13/main.go +++ b/backend/cmd/jiang13/main.go @@ -33,7 +33,7 @@ func main() { } addr := fmt.Sprintf(":%d", cfg.Port) - log.Printf("[jiang13-bbs] 服务启动于 %s (dev=%v)", addr, cfg.DevMode) + log.Printf("[jiang13-bbs] 服务启动于 %s (dev=%v workPath=%s dataDir=%s)", addr, 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 1553587..7b11840 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -20,11 +20,13 @@ type Config struct { DevMode bool } -// Parse 解析配置:环境变量 > app.ini > 默认值 +// Parse 解析配置:环境变量 > app.ini > 默认值。 +// 工作目录不跟 os.Getwd() 走:从 cwd 向上查找 backend 根(含 app.ini 或源码标记), +// 避免在仓库根 / cmd/jiang13 启动时把上传文件写进另一套 data/。 func Parse() (*Config, error) { - workPath, err := os.Getwd() + workPath, err := resolveWorkPath() if err != nil { - return nil, fmt.Errorf("获取工作目录失败: %w", err) + return nil, err } // 默认值 @@ -83,6 +85,10 @@ func Parse() (*Config, error) { cfg.DevMode = strings.EqualFold(v, "true") || v == "1" } + if err := rejectStrayDataDir(cfg.DataDir); err != nil { + return nil, err + } + // 确保数据目录存在 if err := os.MkdirAll(cfg.DataDir, 0755); err != nil { return nil, fmt.Errorf("创建数据目录失败: %w", err) @@ -102,6 +108,68 @@ func Parse() (*Config, error) { return cfg, nil } +// resolveWorkPath 定位后端根目录(app.ini 与 data/ 所在处)。 +// 优先级:JIANG13_WORK_PATH > 从 cwd 向上找 app.ini > 源码树 backend/ > cwd。 +func resolveWorkPath() (string, error) { + if v := strings.TrimSpace(os.Getenv("JIANG13_WORK_PATH")); v != "" { + abs, err := filepath.Abs(v) + if err != nil { + return "", fmt.Errorf("解析 JIANG13_WORK_PATH 失败: %w", err) + } + return abs, nil + } + + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("获取工作目录失败: %w", err) + } + + dir := cwd + for range 8 { + if hasAppIni(dir) { + return dir, nil + } + nested := filepath.Join(dir, "backend") + if hasAppIni(nested) { + return nested, nil + } + if isSourceBackendRoot(dir) { + return dir, nil + } + if isSourceBackendRoot(nested) { + return nested, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return cwd, nil +} + +func hasAppIni(dir string) bool { + _, err := os.Stat(filepath.Join(dir, "app.ini")) + return err == nil +} + +func isSourceBackendRoot(dir string) bool { + _, e1 := os.Stat(filepath.Join(dir, "go.mod")) + _, e2 := os.Stat(filepath.Join(dir, "cmd", "jiang13")) + _, e3 := os.Stat(filepath.Join(dir, "app.ini.example")) + return e1 == nil && e2 == nil && e3 == nil +} + +// rejectStrayDataDir 拦住把上传写进 cmd/jiang13/data 的错误 cwd。 +func rejectStrayDataDir(dataDir string) error { + slash := filepath.ToSlash(dataDir) + if strings.Contains(slash, "/cmd/jiang13/") { + return fmt.Errorf("拒绝使用数据目录 %s:这是在 cmd/jiang13 下启动产生的错误路径。请在 backend/ 执行 go run ./cmd/jiang13,或设置 JIANG13_WORK_PATH 指向 backend 目录", dataDir) + } + return nil +} + func generateSecret(n int) string { const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, n) diff --git a/backend/config/config_test.go b/backend/config/config_test.go new file mode 100644 index 0000000..c38d621 --- /dev/null +++ b/backend/config/config_test.go @@ -0,0 +1,90 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func backendRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + root, err := filepath.Abs(filepath.Join(wd, "..")) + if err != nil { + t.Fatal(err) + } + return root +} + +func assertPath(t *testing.T, got, want string) { + t.Helper() + g, err := filepath.Abs(got) + if err != nil { + t.Fatal(err) + } + w, err := filepath.Abs(want) + if err != nil { + t.Fatal(err) + } + if filepath.Clean(g) != filepath.Clean(w) { + t.Fatalf("路径不符:\n got %s\n want %s", g, w) + } +} + +func TestResolveWorkPathFromOfficialCwd(t *testing.T) { + root := backendRoot(t) + t.Chdir(root) + got, err := resolveWorkPath() + if err != nil { + t.Fatal(err) + } + assertPath(t, got, root) +} + +func TestResolveWorkPathFromCmdJiang13(t *testing.T) { + root := backendRoot(t) + t.Chdir(filepath.Join(root, "cmd", "jiang13")) + got, err := resolveWorkPath() + if err != nil { + t.Fatal(err) + } + assertPath(t, got, root) +} + +func TestResolveWorkPathFromRepoRoot(t *testing.T) { + root := backendRoot(t) + t.Chdir(filepath.Join(root, "..")) + got, err := resolveWorkPath() + if err != nil { + t.Fatal(err) + } + assertPath(t, got, root) +} + +func TestResolveWorkPathEnvOverride(t *testing.T) { + root := backendRoot(t) + t.Setenv("JIANG13_WORK_PATH", root) + t.Chdir(t.TempDir()) + got, err := resolveWorkPath() + if err != nil { + t.Fatal(err) + } + assertPath(t, got, root) +} + +func TestRejectStrayDataDir(t *testing.T) { + err := rejectStrayDataDir(`C:\proj\backend\cmd\jiang13\data`) + if err == nil { + t.Fatal("期望拒绝 cmd/jiang13/data") + } + if !strings.Contains(err.Error(), "拒绝使用数据目录") { + t.Fatalf("错误文案不符: %v", err) + } + if err := rejectStrayDataDir(`C:\proj\backend\data`); err != nil { + t.Fatal(err) + } +} diff --git a/start.bat b/start.bat index c537258..3002d63 100644 --- a/start.bat +++ b/start.bat @@ -25,7 +25,7 @@ if not exist "backend\app.ini" ( ) if not exist "backend\data" mkdir "backend\data" -echo Starting backend (port 3001)... +echo Starting backend (port 3001, cwd=backend, data=backend\data)... start "jiang13-backend" cmd /k "cd /d %~dp0backend && go run ./cmd/jiang13" echo [OK] Backend started echo. @@ -54,6 +54,7 @@ echo All services started! echo. echo Frontend: http://localhost:3000 echo Backend: http://localhost:3001 +echo Data dir: %~dp0backend\data echo Database: localhost:5432 echo. echo Close each window to stop the service. diff --git a/start.sh b/start.sh index 163e370..bf77abf 100644 --- a/start.sh +++ b/start.sh @@ -32,7 +32,7 @@ fi mkdir -p backend/data # 启动后端(后台) -echo " 启动后端 (端口 3001)..." +echo " 启动后端 (端口 3001, cwd=backend, data=backend/data)..." cd "$ROOT_DIR/backend" go run ./cmd/jiang13 & BACKEND_PID=$! @@ -69,6 +69,7 @@ echo " 全部启动完成!" echo "" echo " 前端: http://localhost:3000" echo " 后端: http://localhost:3001" +echo " 数据目录: $ROOT_DIR/backend/data" echo " 数据库: localhost:5432" echo "" echo " 后端 PID: $BACKEND_PID"