fix: 固定开发环境后端数据目录,避免 cwd 不同导致头像 404

从 cwd 向上解析 backend 根,并写入本地开发启动约定。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:13:58 +08:00
parent 833bcd33a1
commit b0170a99fd
10 changed files with 208 additions and 9 deletions

View File

@@ -283,6 +283,7 @@
- **全站图片策略:用户上传的图片统一 WebP**。头像裁剪/转码在浏览器端完成(Canvas → toBlob('image/webp')),后端只收 WebP - **全站图片策略:用户上传的图片统一 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) - 存储:`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 更新在同一事务 - 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;成功后物理删除文件) - 路由(需登录 + 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 - **头像只能走上传/媒体库选用通道**:`UpdateProfile` 已移除 avatar 字段,禁止通过资料接口写任意外链 URL

View File

@@ -26,4 +26,4 @@ alwaysApply: true
- remote:`https://git.iioio.com/freefire/jiang13-bbs.git`,分支 `main` - remote:`https://git.iioio.com/freefire/jiang13-bbs.git`,分支 `main`
- 自建 Gitea 偶发不稳:`git push` 失败则**原样重试一次**;勿改 remote / 勿反复重试 - 自建 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`

View File

@@ -22,6 +22,7 @@ alwaysApply: true
- Avatar 所有调用必须 `src={xxx.avatar || undefined}` - Avatar 所有调用必须 `src={xxx.avatar || undefined}`
- CSRF cookie 是 URLEncoding(含 `=`),解析勿用 `split('=')[1]` - CSRF cookie 是 URLEncoding(含 `=`),解析勿用 `split('=')[1]`
- PowerShell 5.1 中文脚本需 UTF-8 BOM;npx/npm 用 `npx.cmd`/`npm.cmd` - 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) ## 用户偏好(自 Trae user_profile)

View File

@@ -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/`,再删遗留目录。

View File

@@ -122,7 +122,9 @@ go mod download
go run ./cmd/jiang13 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. 启动前端 ### 3. 启动前端
@@ -160,7 +162,7 @@ DATA = data
DEV_MODE = true 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`) ### 前端(`frontend/.env.local`)

View File

@@ -33,7 +33,7 @@ func main() {
} }
addr := fmt.Sprintf(":%d", cfg.Port) 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 { if err := r.Run(addr); err != nil {
log.Fatalf("服务启动失败: %v", err) log.Fatalf("服务启动失败: %v", err)
} }

View File

@@ -20,11 +20,13 @@ type Config struct {
DevMode bool DevMode bool
} }
// Parse 解析配置:环境变量 > app.ini > 默认值 // Parse 解析配置:环境变量 > app.ini > 默认值。
// 工作目录不跟 os.Getwd() 走:从 cwd 向上查找 backend 根(含 app.ini 或源码标记),
// 避免在仓库根 / cmd/jiang13 启动时把上传文件写进另一套 data/。
func Parse() (*Config, error) { func Parse() (*Config, error) {
workPath, err := os.Getwd() workPath, err := resolveWorkPath()
if err != nil { 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" 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 { if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
return nil, fmt.Errorf("创建数据目录失败: %w", err) return nil, fmt.Errorf("创建数据目录失败: %w", err)
@@ -102,6 +108,68 @@ func Parse() (*Config, error) {
return cfg, nil 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 { func generateSecret(n int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n) b := make([]byte, n)

View File

@@ -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)
}
}

View File

@@ -25,7 +25,7 @@ if not exist "backend\app.ini" (
) )
if not exist "backend\data" mkdir "backend\data" 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" start "jiang13-backend" cmd /k "cd /d %~dp0backend && go run ./cmd/jiang13"
echo [OK] Backend started echo [OK] Backend started
echo. echo.
@@ -54,6 +54,7 @@ echo All services started!
echo. echo.
echo Frontend: http://localhost:3000 echo Frontend: http://localhost:3000
echo Backend: http://localhost:3001 echo Backend: http://localhost:3001
echo Data dir: %~dp0backend\data
echo Database: localhost:5432 echo Database: localhost:5432
echo. echo.
echo Close each window to stop the service. echo Close each window to stop the service.

View File

@@ -32,7 +32,7 @@ fi
mkdir -p backend/data mkdir -p backend/data
# 启动后端(后台) # 启动后端(后台)
echo " 启动后端 (端口 3001)..." echo " 启动后端 (端口 3001, cwd=backend, data=backend/data)..."
cd "$ROOT_DIR/backend" cd "$ROOT_DIR/backend"
go run ./cmd/jiang13 & go run ./cmd/jiang13 &
BACKEND_PID=$! BACKEND_PID=$!
@@ -69,6 +69,7 @@ echo " 全部启动完成!"
echo "" echo ""
echo " 前端: http://localhost:3000" echo " 前端: http://localhost:3000"
echo " 后端: http://localhost:3001" echo " 后端: http://localhost:3001"
echo " 数据目录: $ROOT_DIR/backend/data"
echo " 数据库: localhost:5432" echo " 数据库: localhost:5432"
echo "" echo ""
echo " 后端 PID: $BACKEND_PID" echo " 后端 PID: $BACKEND_PID"