fix: 固定开发环境后端数据目录,避免 cwd 不同导致头像 404
从 cwd 向上解析 backend 根,并写入本地开发启动约定。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
35
.cursor/rules/jiang13-bbs-local-dev.mdc
Normal file
35
.cursor/rules/jiang13-bbs-local-dev.mdc
Normal 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/`,再删遗留目录。
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
90
backend/config/config_test.go
Normal file
90
backend/config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
3
start.sh
3
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"
|
||||
|
||||
Reference in New Issue
Block a user