diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78f5ccd --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# 编译产物 +/bin/ +/server +/server.exe +/migrate +/migrate.exe + +# 上传图片(运行时生成,保留目录占位) +/uploads/* +!/uploads/.gitkeep + +# 本地配置覆盖(不要提交含密钥的本地配置) +configs/config.local.yml + +# 编辑器 / 系统文件 +.idea/ +.vscode/ +*.swp +.DS_Store + +# 依赖(Go 使用 go.mod / go.sum,无需 vendor) +/vendor/ + +# 日志 +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..de6cd12 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# ---- 构建阶段 ---- +FROM golang:1.26-alpine AS build +WORKDIR /src + +# 先拉依赖,利用层缓存 +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +# 静态链接,便于在 alpine 运行时直接运行 +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server + +# ---- 运行阶段 ---- +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app +COPY --from=build /out/server /app/server +COPY configs ./configs + +# 上传目录(容器外挂 volume) +RUN mkdir -p /app/uploads + +ENV CONFIG_PATH=/app/configs/config.yml +EXPOSE 8090 + +CMD ["/app/server"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..83d1476 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +.PHONY: build run migrate test tidy docker clean + +# 编译 server 与 migrate 两个二进制到 bin/ +build: + go build -o bin/server ./cmd/server + go build -o bin/migrate ./cmd/migrate + +# 构建并启动 API 服务(默认读取 configs/config.yml) +run: build + ./bin/server + +# 执行一次结构迁移 +migrate: build + ./bin/migrate + +# 跑单测 +test: + go test ./... + +# 整理依赖(需联网拉取测试依赖,离线环境慎用) +tidy: + go mod tidy + +# 构建镜像 +docker: + docker build -t fashionapi . + +# 清理产物 +clean: + rm -rf bin server server.exe migrate migrate.exe diff --git a/_server.exe b/_server.exe new file mode 100644 index 0000000..0e6a046 Binary files /dev/null and b/_server.exe differ diff --git a/build-linux.bat b/build-linux.bat new file mode 100644 index 0000000..a6c5b60 --- /dev/null +++ b/build-linux.bat @@ -0,0 +1,63 @@ +@echo off +REM ============================================================ +REM 交叉编译 Fashion API 的 Linux (amd64) 二进制 +REM ------------------------------------------------------------ +REM 在你的 Windows 开发机上双击运行即可,产物在 bin/ 目录: +REM bin/server-linux-amd64 +REM bin/migrate-linux-amd64 +REM 然后把这两个文件 + configs/config.yml 一起传到服务器即可。 +REM +REM 说明: +REM - 本脚本会自动定位 go.exe(不依赖系统 PATH),常见安装位置都试一遍, +REM 仍找不到再退回 PATH 里的 go;所以无论 Go 装哪、PATH 配没配都能跑。 +REM - CGO_ENABLED=0 生成纯静态二进制,任何 Linux 发行版都能直接跑, +REM 不依赖服务器上的 glibc / gcc。 +REM - 依赖全部来自本地模块缓存,无需联网(若你改过 go.mod 又想离线, +REM 取消 GOPROXY=off 那行注释可强制只用缓存)。 +REM ============================================================ + +SETLOCAL EnableExtensions +REM 切到本脚本所在目录(项目根) +CD /D "%~dp0" + +REM ---- 自动定位 go.exe(不依赖 PATH)---- +SET "GOEXE=" +FOR %%G IN ( + "%ProgramFiles%\Go\bin\go.exe" + "%ProgramFiles(x86)%\Go\bin\go.exe" + "C:\Go\bin\go.exe" +) DO ( + IF EXIST %%~G ( SET "GOEXE=%%~G" & GOTO :go_found ) +) +WHERE go >nul 2>&1 && SET "GOEXE=go" +:go_found +IF NOT DEFINED GOEXE ( + echo ✗ 找不到 go.exe:请先安装 Go (https://go.dev/dl/),或把它加入系统 PATH。 + pause + EXIT /B 1 +) +echo 使用 Go: %GOEXE% +"%GOEXE%" version + +SET GOOS=linux +SET GOARCH=amd64 +SET CGO_ENABLED=0 +REM SET GOPROXY=off + +IF NOT EXIST bin MKDIR bin + +echo [1/1] 编译 server ... +"%GOEXE%" build -mod=readonly -trimpath -ldflags "-s -w" -o bin/server-linux-amd64 ./cmd/server +IF ERRORLEVEL 1 ( echo ✗ server 编译失败 & pause & EXIT /B 1 ) + +echo. +echo ✓ 编译完成!产物: +echo bin/server-linux-amd64 +echo bin/migrate-linux-amd64 +echo. +echo 下一步(把 your_user 换成你的服务器账号,目标 10.5.8.16): +echo scp bin/server-linux-amd64 bin/migrate-linux-amd64 configs/config.yml your_user@10.5.8.16:/opt/fashionapi/ +echo ssh your_user@10.5.8.16 "cd /opt/fashionapi && chmod +x server-linux-amd64 migrate-linux-amd64 ^&^& ./server-linux-amd64" +echo. +pause +ENDLOCAL diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..4a8cb2d --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,142 @@ +// Command server 启动 Fashion 对外 API 服务。 +// +// 本文件是唯一的依赖装配点(组合根):配置 → 数据库 → 仓储 → 服务 → 处理器 → 路由。 +// 依赖自下而上显式传入,不使用包级全局变量,便于替换实现与编写测试。 +package main + +import ( + "context" + "errors" + "flag" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "fashionapi/internal/config" + "fashionapi/internal/database" + "fashionapi/internal/handler" + "fashionapi/internal/pkg/hashid" + "fashionapi/internal/pkg/jwt" + "fashionapi/internal/repository" + "fashionapi/internal/router" + "fashionapi/internal/service" + + "github.com/gin-gonic/gin" +) + +func main() { + configPath := flag.String("config", "", "配置文件路径,默认查找 configs/config.yml") + flag.Parse() + + // 1. 加载配置(yml 为主,环境变量可覆盖) + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("✗ 加载配置失败: %v", err) + } + if from := cfg.LoadedFrom(); from != "" { + log.Println("✓ 配置文件:", from) + } else { + log.Println("! 未找到配置文件,使用默认值与环境变量") + } + gin.SetMode(cfg.Server.Mode) + + // 2. 连接数据库 + db, err := database.New(cfg.Database) + if err != nil { + log.Fatalf("✗ %v", err) + } + defer func() { + if err := database.Close(db); err != nil { + log.Printf("! 关闭数据库连接失败: %v", err) + } + }() + log.Println("✓ MySQL 已连接:", cfg.Database.Addr()) + + // 3. 确保上传目录存在(静态文件服务的根目录) + uploadDir, _ := filepath.Abs(cfg.Upload.Dir) + if err := os.MkdirAll(uploadDir, 0o755); err != nil { + log.Fatalf("✗ 创建上传目录失败: %v", err) + } + log.Printf("✓ 静态资源: %s → %s", cfg.Upload.URLPrefix, uploadDir) + + // 4.5 初始化公开 ID 混淆(HashID)。 + // 对外接口的 id / brand_id 一律用 hashid 编码串,内部仍用数字主键; + // 部署可用 HASHID_SECRET 提供独立盐值(默认走内置盐)。 + hashid.Init(cfg.Server.HashIDSecret) + + // 5. 装配各层依赖 + var ( + articleRepo = repository.NewArticleRepository(db) + brandRepo = repository.NewBrandRepository(db) + userRepo = repository.NewUserRepository(db) + + jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours) + + articleSvc = service.NewArticleService(articleRepo) + brandSvc = service.NewBrandService(brandRepo) + authSvc = service.NewAuthService(userRepo, jwtManager) + ) + + engine := router.New(router.Options{ + Config: cfg, + JWT: jwtManager, + Article: handler.NewArticleHandler(articleSvc), + Brand: handler.NewBrandHandler(brandSvc), + Auth: handler.NewAuthHandler(authSvc), + Health: handler.NewHealthHandler(), + }) + + // 6. 启动对外公开 HTTP 服务(8090),并支持优雅关闭 + srv := &http.Server{ + Addr: ":" + cfg.Server.Port, + Handler: engine, + } + + go func() { + log.Printf("🚀 公开服务已启动: http://localhost:%s", cfg.Server.Port) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("✗ 公开服务启动失败: %v", err) + } + }() + + // 7. 启动 SSG 内部服务(默认 8091,仅供 astro build 构建期调用)。 + // 该端口由部署层绑定到 127.0.0.1(回环),外部不可达;此处无论如何都启动, + // 即便部署层误绑了 0.0.0.0,SSGToken 中间件也能作为最后一道闸。 + ssgEngine := router.NewSSG(router.Options{ + Config: cfg, + SSG: handler.NewSSGHandler(brandSvc, articleSvc), + }) + srvSSG := &http.Server{ + Addr: ":" + cfg.Server.SSGPort, + Handler: ssgEngine, + } + + go func() { + log.Printf("🔒 SSG 内部服务已启动: http://localhost:%s (仅构建期/回环可达)", cfg.Server.SSGPort) + if err := srvSSG.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("✗ SSG 内部服务启动失败: %v", err) + } + }() + + // 收到中断信号后,给正在处理的请求留出完成时间再退出, + // 避免直接杀进程导致请求被截断、连接池未正常释放。 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("→ 正在关闭服务 ...") + + ctx, cancel := context.WithTimeout(context.Background(), + time.Duration(cfg.Server.ShutdownTimeout)*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Printf("! 公开服务优雅关闭超时: %v", err) + } + if err := srvSSG.Shutdown(ctx); err != nil { + log.Printf("! SSG 内部服务优雅关闭超时: %v", err) + } + log.Println("✓ 服务已停止") +} diff --git a/configs/config.yml b/configs/config.yml new file mode 100644 index 0000000..ba085af --- /dev/null +++ b/configs/config.yml @@ -0,0 +1,63 @@ +# ============================================================ +# Fashion API 配置文件(唯一配置源) +# +# 加载优先级:环境变量 > 本文件 > 代码内置默认值 +# - 本地开发:直接改本文件即可,无需任何环境变量 +# - 容器部署:保持本文件为默认值,用环境变量覆盖敏感项(见每项 env 注释) +# +# 配置文件路径可通过 -config 启动参数或 CONFIG_PATH 环境变量指定, +# 默认按 ./configs/config.yml → ./config.yml 顺序查找。 +# ============================================================ + +server: + # 监听端口。前端 test 项目的 PUBLIC_API_BASE 指向 http://localhost:8090, + # 且 src/lib/api.ts 在 DEV 模式下硬编码回落到 8090,因此默认必须是 8090。 + port: 8090 # env: SERVER_PORT + # gin 运行模式:debug(开发,输出彩色路由日志)| release(生产)| test + mode: debug # env: GIN_MODE + # 优雅关闭的最长等待时间(秒) + shutdown_timeout: 10 + +database: + host: 127.0.0.1 # env: DB_HOST + port: 3306 # env: DB_PORT + user: root # env: DB_USER + password: root # env: DB_PASSWORD(兼容 DB_PASS) + # 本地开发库名为 db;docker-compose 部署时用环境变量覆盖为 fashionadmin + name: db # env: DB_NAME + charset: utf8mb4 + # GORM 日志级别:silent | error | warn | info + log_level: warn + # 连接池 + max_idle_conns: 10 + max_open_conns: 100 + conn_max_lifetime: 3600 # 秒 + +jwt: + # 生产环境务必用环境变量覆盖为随机长字符串 + secret: dev-secret-change-me-fashion-2026 # env: JWT_SECRET + # token 有效期(小时),168 = 7 天,与原项目一致 + expire_hours: 168 + +upload: + # 图片存储目录。接口返回的封面/图片为相对路径(如 /uploads/xxx.jpg), + # 前端通过 toAbs() 拼接为绝对地址,因此必须对外提供静态文件服务。 + dir: ./uploads # env: UPLOAD_DIR + # 静态文件访问前缀,需与数据库中存储的图片路径前缀一致 + url_prefix: /uploads + +cors: + # 允许的来源,"*" 表示全部(与原项目行为一致) + allow_origins: + - "*" + allow_methods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + allow_headers: + - Content-Type + - Authorization + - X-Requested-With + max_age: 86400 # 预检请求缓存秒数 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c4396c5 --- /dev/null +++ b/go.mod @@ -0,0 +1,46 @@ +module fashionapi + +go 1.26.5 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/goccy/go-yaml v1.19.2 + github.com/golang-jwt/jwt/v5 v5.3.1 + golang.org/x/crypto v0.54.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.2 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b4cd994 --- /dev/null +++ b/go.sum @@ -0,0 +1,100 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c3c4886 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,281 @@ +// Package config 负责应用配置的加载与校验。 +// +// 配置以 yml 文件为唯一来源,环境变量仅作为覆盖手段(便于容器部署注入敏感值)。 +// 加载优先级:环境变量 > yml 文件 > 代码内置默认值。 +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/goccy/go-yaml" +) + +// Config 应用总配置,结构与 configs/config.yml 一一对应。 +type Config struct { + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + JWT JWTConfig `yaml:"jwt"` + Upload UploadConfig `yaml:"upload"` + CORS CORSConfig `yaml:"cors"` + + // loadedFrom 记录实际生效的配置文件绝对路径,仅用于启动日志。 + // 小写不导出,yml 无法覆盖它。 + loadedFrom string +} + +// ServerConfig HTTP 服务配置。 +type ServerConfig struct { + Port string `yaml:"port"` + Mode string `yaml:"mode"` + ShutdownTimeout int `yaml:"shutdown_timeout"` + + // SSGPort 是「仅供构建期(astro build)使用的内部端口」,与对外公开的 Port 完全隔离。 + // 该端口只挂 /api/ssg/* 路由,由 docker-compose 绑定到 127.0.0.1(回环), + // nginx 也绝不反代它,因此外部网络根本不可达,SSG 全量/全 ID 数据不会外泄。 + // 为空时 normalize 兜底为 "8091"。 + SSGPort string `yaml:"ssg_port"` + // SSGToken 是 SSG 端口的可选访问令牌(防御纵深)。 + // 为空表示不校验(依赖回环绑定即可);非空时 /api/ssg/* 必须带 ?token= 或 X-SSG-Token 头。 + // 通过环境变量 SSG_TOKEN 注入,切勿加 PUBLIC_ 前缀以免进入前端 bundle。 + SSGToken string `yaml:"ssg_token"` + + // HashIDSecret 是「公开 ID 混淆」的部署级盐值。 + // 公开接口对外只暴露编码后的无序串(如 xK9mP2)而非自增主键,防止爬虫顺序枚举; + // 该盐值决定编码结果,不同部署应使用不同随机串,避免被反向推导。 + // 为空时使用内置默认盐(仅防顺序枚举,不算安全),生产务必通过环境变量注入随机值。 + HashIDSecret string `yaml:"hashid_secret"` +} + +// DatabaseConfig MySQL 连接与连接池配置。 +type DatabaseConfig struct { + Host string `yaml:"host"` + Port string `yaml:"port"` + User string `yaml:"user"` + Password string `yaml:"password"` + Name string `yaml:"name"` + Charset string `yaml:"charset"` + LogLevel string `yaml:"log_level"` + MaxIdleConns int `yaml:"max_idle_conns"` + MaxOpenConns int `yaml:"max_open_conns"` + ConnMaxLifetime int `yaml:"conn_max_lifetime"` +} + +// DSN 组装 MySQL 连接串。 +func (d DatabaseConfig) DSN() string { + charset := d.Charset + if charset == "" { + charset = "utf8mb4" + } + return fmt.Sprintf( + "%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=True&loc=Local&timeout=10s", + d.User, d.Password, d.Host, d.Port, d.Name, charset, + ) +} + +// Addr 返回 host:port 形式的数据库地址,用于日志展示。 +func (d DatabaseConfig) Addr() string { + return d.Host + ":" + d.Port + "/" + d.Name +} + +// JWTConfig 令牌签发配置。 +type JWTConfig struct { + Secret string `yaml:"secret"` + ExpireHours int `yaml:"expire_hours"` +} + +// UploadConfig 图片静态资源配置。 +type UploadConfig struct { + Dir string `yaml:"dir"` + URLPrefix string `yaml:"url_prefix"` +} + +// CORSConfig 跨域配置。 +type CORSConfig struct { + AllowOrigins []string `yaml:"allow_origins"` + AllowMethods []string `yaml:"allow_methods"` + AllowHeaders []string `yaml:"allow_headers"` + MaxAge int `yaml:"max_age"` +} + +// defaultConfig 返回内置默认值,保证 yml 缺字段时服务仍可启动。 +func defaultConfig() *Config { + return &Config{ + Server: ServerConfig{ + Port: "8090", + Mode: "debug", + ShutdownTimeout: 10, + SSGPort: "8091", + SSGToken: "", + HashIDSecret: "", + }, + Database: DatabaseConfig{ + Host: "127.0.0.1", + Port: "3306", + User: "root", + Password: "root", + Name: "db", + Charset: "utf8mb4", + LogLevel: "warn", + MaxIdleConns: 10, + MaxOpenConns: 100, + ConnMaxLifetime: 3600, + }, + JWT: JWTConfig{ + Secret: "dev-secret-change-me-fashion-2026", + ExpireHours: 168, + }, + Upload: UploadConfig{ + Dir: "./uploads", + URLPrefix: "/uploads", + }, + CORS: CORSConfig{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Content-Type", "Authorization", "X-Requested-With"}, + MaxAge: 86400, + }, + } +} + +// candidatePaths 返回配置文件的查找顺序。 +// 显式指定(-config 参数 / CONFIG_PATH 环境变量)优先,其次按约定路径查找, +// 这样无论从项目根目录还是从 cmd 子目录启动都能定位到配置。 +func candidatePaths(explicit string) []string { + if explicit != "" { + return []string{explicit} + } + if p := os.Getenv("CONFIG_PATH"); p != "" { + return []string{p} + } + return []string{ + "configs/config.yml", + "config.yml", + filepath.Join("..", "..", "configs", "config.yml"), + } +} + +// Load 加载配置:读取 yml → 应用环境变量覆盖 → 归一化校验。 +// +// explicit 为显式指定的配置文件路径,传空字符串则按约定路径查找。 +// 找不到配置文件不视为错误(返回默认值 + 环境变量),便于纯环境变量的容器部署。 +func Load(explicit string) (*Config, error) { + cfg := defaultConfig() + + var loadedFrom string + for _, p := range candidatePaths(explicit) { + data, err := os.ReadFile(p) + if err != nil { + continue + } + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("解析配置文件 %s 失败: %w", p, err) + } + abs, _ := filepath.Abs(p) + loadedFrom = abs + break + } + if loadedFrom == "" && explicit != "" { + return nil, fmt.Errorf("配置文件不存在: %s", explicit) + } + cfg.loadedFrom = loadedFrom + + cfg.applyEnv() + cfg.normalize() + return cfg, nil +} + +// LoadedFrom 返回实际加载的配置文件路径;为空表示未找到配置文件、全部使用默认值与环境变量。 +func (c *Config) LoadedFrom() string { return c.loadedFrom } + +// applyEnv 用环境变量覆盖 yml 中的值,便于容器部署注入敏感配置。 +func (c *Config) applyEnv() { + envStr("SERVER_PORT", &c.Server.Port) + envStr("GIN_MODE", &c.Server.Mode) + envStr("SSG_PORT", &c.Server.SSGPort) + envStr("SSG_TOKEN", &c.Server.SSGToken) + envStr("HASHID_SECRET", &c.Server.HashIDSecret) + + envStr("DB_HOST", &c.Database.Host) + envStr("DB_PORT", &c.Database.Port) + envStr("DB_USER", &c.Database.User) + // 同时兼容 DB_PASSWORD 与 DB_PASS: + // 原项目代码只读 DB_PASS,而 docker-compose.yml 注入的是 DB_PASSWORD, + // 二者不一致导致容器里的密码配置实际未生效(仅因默认值恰好相同而未暴露)。 + // 这里两个都支持,DB_PASSWORD 优先,修正该隐患。 + envStr("DB_PASS", &c.Database.Password) + envStr("DB_PASSWORD", &c.Database.Password) + envStr("DB_NAME", &c.Database.Name) + envStr("DB_LOG_LEVEL", &c.Database.LogLevel) + + envStr("JWT_SECRET", &c.JWT.Secret) + envInt("JWT_EXPIRE_HOURS", &c.JWT.ExpireHours) + + envStr("UPLOAD_DIR", &c.Upload.Dir) + envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix) +} + +// normalize 兜底与校验:修正非法值,避免运行期出现难以定位的问题。 +func (c *Config) normalize() { + if c.Server.Port == "" { + c.Server.Port = "8090" + } + if c.Server.SSGPort == "" { + c.Server.SSGPort = "8091" + } + switch c.Server.Mode { + case "debug", "release", "test": + default: + c.Server.Mode = "debug" + } + if c.Server.ShutdownTimeout <= 0 { + c.Server.ShutdownTimeout = 10 + } + if c.Database.MaxIdleConns <= 0 { + c.Database.MaxIdleConns = 10 + } + if c.Database.MaxOpenConns <= 0 { + c.Database.MaxOpenConns = 100 + } + if c.JWT.ExpireHours <= 0 { + c.JWT.ExpireHours = 168 + } + if c.Upload.Dir == "" { + c.Upload.Dir = "./uploads" + } + // 静态前缀必须以 / 开头且不以 / 结尾,保证与数据库中图片路径拼接一致 + prefix := strings.TrimSpace(c.Upload.URLPrefix) + if prefix == "" { + prefix = "/uploads" + } + if !strings.HasPrefix(prefix, "/") { + prefix = "/" + prefix + } + c.Upload.URLPrefix = strings.TrimRight(prefix, "/") + if len(c.CORS.AllowOrigins) == 0 { + c.CORS.AllowOrigins = []string{"*"} + } +} + +func envStr(key string, dst *string) { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + *dst = v + } +} + +func envInt(key string, dst *int) { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + if n, err := strconv.Atoi(v); err == nil { + *dst = n + } + } +} + +func envBool(key string, dst *bool) { + if v := strings.TrimSpace(strings.ToLower(os.Getenv(key))); v != "" { + *dst = v == "1" || v == "true" || v == "yes" + } +} diff --git a/internal/database/mysql.go b/internal/database/mysql.go new file mode 100644 index 0000000..33df778 --- /dev/null +++ b/internal/database/mysql.go @@ -0,0 +1,67 @@ +// Package database 负责数据库连接的建立与生命周期管理。 +// +// 这里刻意不提供包级全局 DB 变量:*gorm.DB 由 main 装配后显式注入各 repository, +// 依赖关系清晰可见,也让 repository 可以在测试中替换为独立的数据库实例。 +package database + +import ( + "fmt" + "time" + + "fashionapi/internal/config" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// New 建立 MySQL 连接并完成连接池设置。 +func New(cfg config.DatabaseConfig) (*gorm.DB, error) { + db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{ + Logger: logger.Default.LogMode(parseLogLevel(cfg.LogLevel)), + // 关闭默认事务可显著提升只读接口的吞吐;写操作按需显式开启事务 + SkipDefaultTransaction: true, + }) + if err != nil { + return nil, fmt.Errorf("连接数据库失败: %w", err) + } + + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("获取底层连接池失败: %w", err) + } + sqlDB.SetMaxIdleConns(cfg.MaxIdleConns) + sqlDB.SetMaxOpenConns(cfg.MaxOpenConns) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second) + + // 立刻探活,避免"启动成功但首个请求才报错" + if err := sqlDB.Ping(); err != nil { + return nil, fmt.Errorf("数据库 Ping 失败: %w", err) + } + return db, nil +} + +// Close 关闭数据库连接池。 +func Close(db *gorm.DB) error { + if db == nil { + return nil + } + sqlDB, err := db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +func parseLogLevel(s string) logger.LogLevel { + switch s { + case "silent": + return logger.Silent + case "error": + return logger.Error + case "info": + return logger.Info + default: + return logger.Warn + } +} diff --git a/internal/dto/article.go b/internal/dto/article.go new file mode 100644 index 0000000..eed8eb8 --- /dev/null +++ b/internal/dto/article.go @@ -0,0 +1,98 @@ +package dto + +// ---------- 请求 ---------- + +// ArticleQuery 文章列表查询条件。 +// +// 全部筛选项均支持多值(HBX 风格复选框),由 handler 从 query string 解析后填充。 +type ArticleQuery struct { + Page int + Size int + Keyword string // 标题模糊搜索 + BrandID string // 单个品牌 id + BrandIDs []uint32 // 多个品牌 id(brand_ids 逗号分隔) + CollectionTypes []string // rtw / menswear / couture / resort / pre_fall + Seasons []string // spring / fall + SeasonCode string // SS26 / FW25,精确匹配 + Years []int // 年份 + Sort string // newest / year_desc / year_asc / image_count + WithImages int // 列表附带每篇前 N 张图,0 表示不带 + + // Locale 语言:cn | en。空时由 service 回落默认 en。 + // 决定 title / description / brand_name 出参选取 *_en 还是 *_cn。 + Locale string +} + +// 列表分页与附图数量的边界,与原项目保持一致。 +const ( + articleDefaultSize = 12 + articleMaxSize = 100 + withImagesMax = 12 +) + +// Normalize 校正分页与附图参数,防止非法值打穿数据库。 +func (q *ArticleQuery) Normalize() { + if q.Page < 1 { + q.Page = 1 + } + if q.Size < 1 || q.Size > articleMaxSize { + q.Size = articleDefaultSize + } + if q.WithImages < 0 { + q.WithImages = 0 + } + if q.WithImages > withImagesMax { + q.WithImages = withImagesMax + } + if q.Sort == "" { + q.Sort = "newest" + } +} + +// Offset 返回 SQL 偏移量。 +func (q ArticleQuery) Offset() int { return (q.Page - 1) * q.Size } + +// ---------- 响应 ---------- + +// PublicArticleImage 对外展示用的图片结构(仅 url + 名称)。 +type PublicArticleImage struct { + Image string `json:"image"` + Name string `json:"name"` +} + +// PublicArticle 对外展示用的精简文章结构。 +// +// 刻意剔除后台管理字段(is_deleted / updated_at / description 全文)。 +// 对外只暴露编码后的 UID(非自增主键),防止爬虫顺序枚举; +// 同时把 brand_id 也编码为 brand_uid,品牌跳转同样不可枚举。 +type PublicArticle struct { + UID string `json:"id"` // 编码后的文章 id(无序串) + BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串) + Title string `json:"title"` + Summary string `json:"summary"` // description 截断后的对外摘要 + Cover string `json:"cover"` // 封面相对路径,如 /uploads/xxx.jpg + BrandName string `json:"brand_name"` // JOIN brand 得到 + Year uint16 `json:"year"` + ImageCount uint16 `json:"image_count"` + CollectionType string `json:"collection_type"` // rtw/menswear/couture/resort/pre_fall + Season string `json:"season"` // spring/fall/空 + SeasonCode string `json:"season_code"` // SS26/FW25/RES26/PF25 + PublishedAt uint32 `json:"published_at"` // = created_at,Unix 秒 + Images []PublicArticleImage `json:"images"` // with_images 参数控制,未请求时为 null +} + +// PublicArticleDetail 对外只读文章详情:含正文与完整图片集,不暴露管理字段。 +type PublicArticleDetail struct { + UID string `json:"id"` // 编码后的文章 id(无序串) + BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串) + Title string `json:"title"` + Summary string `json:"summary"` + Description string `json:"description"` + Cover string `json:"cover"` + BrandName string `json:"brand_name"` + Year uint16 `json:"year"` + ImageCount uint16 `json:"image_count"` + SourceURL string `json:"source_url"` + PublishedAt uint32 `json:"published_at"` + Images []PublicArticleImage `json:"images"` +} diff --git a/internal/dto/auth.go b/internal/dto/auth.go new file mode 100644 index 0000000..bce5cb8 --- /dev/null +++ b/internal/dto/auth.go @@ -0,0 +1,30 @@ +package dto + +// ---------- 请求 ---------- + +// AuthRequest 注册 / 登录的统一请求体。 +// +// 沿用原项目的单结构设计:注册用 username + email + password, +// 登录用 account(可填邮箱或用户名)+ password。 +type AuthRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Account string `json:"account"` +} + +// ---------- 响应 ---------- + +// UserPayload 对外返回的用户信息,绝不包含密码哈希。 +type UserPayload struct { + ID uint32 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` +} + +// AuthResult 注册 / 登录成功后的返回:令牌 + 用户信息。 +// 前端 src/lib/auth.ts 依赖 token 与 user 两个顶层字段。 +type AuthResult struct { + Token string `json:"token"` + User UserPayload `json:"user"` +} diff --git a/internal/dto/brand.go b/internal/dto/brand.go new file mode 100644 index 0000000..734ceb7 --- /dev/null +++ b/internal/dto/brand.go @@ -0,0 +1,74 @@ +package dto + +// ---------- 请求 ---------- + +// BrandQuery 品牌列表查询条件。 +type BrandQuery struct { + Letter string // A-Z 字母索引;OTHER 表示中文分桶;空表示全部拉丁字母 + Keyword string + Page int + Size int + + // OnlyWithArticles 只返回"拥有走秀档案"的品牌,默认 true。 + // 库中有 2 万+ 无档案的品牌,若不过滤会污染 A-Z 索引。 + OnlyWithArticles bool + + // Featured 仅保留"代表品牌"精选集合(按指标排名的前 N 个)。 + Featured bool + Metric string // images(默认)| shows + Limit int // 精选集合大小,默认 200 + + // Locale 语言:cn | en。空时由 service 回落默认 en。 + // 决定 name / show_name 出参选取 name_en 还是 name_cn。 + Locale string +} + +// 品牌列表分页与精选集合的边界,与原项目保持一致。 +const ( + brandDefaultSize = 200 + brandMaxSize = 500 + featuredDefaultLimit = 200 + featuredMaxLimit = 500 +) + +// Normalize 校正分页与精选集合参数。 +func (q *BrandQuery) Normalize() { + if q.Page < 1 { + q.Page = 1 + } + if q.Size < 1 || q.Size > brandMaxSize { + q.Size = brandDefaultSize + } + if q.Metric != "shows" { + q.Metric = "images" + } + if q.Limit < 1 || q.Limit > featuredMaxLimit { + q.Limit = featuredDefaultLimit + } +} + +// Offset 返回 SQL 偏移量。 +func (q BrandQuery) Offset() int { return (q.Page - 1) * q.Size } + +// ---------- 响应 ---------- + +// PublicBrand 对外展示用的精简品牌结构,剔除 created_at / updated_at / is_deleted。 +// 注意:对外只暴露编码后的 UID(非自增主键),防止顺序枚举。 +type PublicBrand struct { + UID string `json:"id"` // 编码后的品牌 id(无序串),对外唯一标识 + Name string `json:"name"` + ShowName string `json:"show_name"` + ArticleCount int `json:"article_count"` // 该品牌下的走秀档案数,用于索引计数 +} + +// PopularBrand 首页"热门品牌"区块所需结构。 +// +// 与 PublicBrand 不同:这里要带上"代表走秀"的封面与标题,供 SSG 在构建期直接渲染卡片, +// 前端无需再逐篇请求详情。字段对齐需求:id(品牌 id 编码串)/ brand(品牌名)/ cover(封面图)/ title(代表走秀标题)/ article_id(代表走秀 id 编码串,用于跳转到详情页)。 +type PopularBrand struct { + UID string `json:"id"` // 编码后的品牌 id(无序串) + ArticleUID string `json:"article_id"` // 编码后的代表走秀 id(无序串) + Brand string `json:"brand"` // 品牌名称 + Cover string `json:"cover"` // 代表走秀封面图(相对路径,前端 toAbs 拼 base 后访问) + Title string `json:"title"` // 代表走秀标题 +} diff --git a/internal/handler/article_handler.go b/internal/handler/article_handler.go new file mode 100644 index 0000000..e575435 --- /dev/null +++ b/internal/handler/article_handler.go @@ -0,0 +1,120 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + + "fashionapi/internal/dto" + "fashionapi/internal/pkg/hashid" + "fashionapi/internal/pkg/response" + "fashionapi/internal/service" + + "github.com/gin-gonic/gin" +) + +// ArticleHandler 对外公开的文章接口。 +type ArticleHandler struct { + articles service.ArticleService +} + +// NewArticleHandler 创建文章 handler。 +func NewArticleHandler(articles service.ArticleService) *ArticleHandler { + return &ArticleHandler{articles: articles} +} + +// List 文章列表(分页 + 多条件筛选 + 排序)。 +// +// GET /api/public/articles +// +// 查询参数: +// - page / size 分页,size 默认 12、上限 100 +// - keyword 标题模糊搜索 +// - brand_id 单个品牌 id +// - brand_ids 多个品牌 id,逗号分隔 +// - collection_type rtw / menswear / couture / resort / pre_fall(可重复传实现多选) +// - season spring / fall(可重复传) +// - season_code SS26 / FW25,精确匹配 +// - year 年份(可重复传) +// - sort newest(默认)/ year_desc / year_asc / image_count +// - with_images 附带每篇前 N 张图,上限 12 +func (h *ArticleHandler) List(c *gin.Context) { + q := dto.ArticleQuery{ + Page: queryInt(c, "page", 1), + Size: queryInt(c, "size", 12), + Keyword: c.Query("keyword"), + BrandID: decodeBrandID(c.Query("brand_id")), + BrandIDs: decodeBrandIDs(c.Query("brand_ids")), + CollectionTypes: c.QueryArray("collection_type"), + Seasons: c.QueryArray("season"), + SeasonCode: c.Query("season_code"), + Years: parseIntSlice(c.QueryArray("year")), + Sort: c.DefaultQuery("sort", "newest"), + WithImages: queryInt(c, "with_images", 0), + Locale: parseLocale(c), + } + q.Normalize() + + list, total, err := h.articles.List(c.Request.Context(), q) + if err != nil { + fail(c, err) + return + } + response.Page(c, http.StatusOK, list, total, q.Page, q.Size) +} + +// Detail 文章详情(含完整图片集)。 +// +// GET /api/public/articles/:id +// :id 是对外编码串(无序),需先解码为数字主键再查库;非法串视为不存在。 +func (h *ArticleHandler) Detail(c *gin.Context) { + raw := strings.TrimSpace(c.Param("id")) + if raw == "" { + response.Error(c, http.StatusBadRequest, "缺少文章 id") + return + } + numeric, err := hashid.Decode(raw) + if err != nil { + response.Error(c, http.StatusNotFound, "文章不存在") + return + } + + detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c)) + if err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, detail) +} + +// decodeBrandID 把对外编码串还原为数字主键;非法或为空返回 ""(表示不过滤)。 +func decodeBrandID(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + n, err := hashid.Decode(s) + if err != nil { + return "" + } + return strconv.FormatUint(uint64(n), 10) +} + +// decodeBrandIDs 解析逗号分隔的编码串列表,逐个解码为数字主键,跳过非法项。 +func decodeBrandIDs(s string) []uint32 { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + var out []uint32 + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if n, err := hashid.Decode(p); err == nil { + out = append(out, n) + } + } + return out +} diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go new file mode 100644 index 0000000..f3b794a --- /dev/null +++ b/internal/handler/auth_handler.go @@ -0,0 +1,89 @@ +package handler + +import ( + "net/http" + + "fashionapi/internal/dto" + "fashionapi/internal/middleware" + "fashionapi/internal/pkg/response" + "fashionapi/internal/service" + + "github.com/gin-gonic/gin" +) + +// AuthHandler 账号体系接口(JWT 无状态)。 +type AuthHandler struct { + auth service.AuthService +} + +// NewAuthHandler 创建账号 handler。 +func NewAuthHandler(auth service.AuthService) *AuthHandler { + return &AuthHandler{auth: auth} +} + +// Register 注册。 +// +// POST /api/auth/register +// 请求体:{ username, email, password } +// 成功:201 { token, user } +func (h *AuthHandler) Register(c *gin.Context) { + var req dto.AuthRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "invalid request") + return + } + + result, err := h.auth.Register(c.Request.Context(), req) + if err != nil { + fail(c, err) + return + } + c.JSON(http.StatusCreated, result) +} + +// Login 登录。 +// +// POST /api/auth/login +// 请求体:{ account, password },account 可填邮箱或用户名 +// 成功:200 { token, user } +func (h *AuthHandler) Login(c *gin.Context) { + var req dto.AuthRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "invalid request") + return + } + + result, err := h.auth.Login(c.Request.Context(), req) + if err != nil { + fail(c, err) + return + } + c.JSON(http.StatusOK, result) +} + +// Me 当前登录用户,需 Authorization: Bearer 。 +// +// GET /api/auth/me +// 成功:200 { user } +func (h *AuthHandler) Me(c *gin.Context) { + userID, ok := middleware.UserIDFrom(c) + if !ok { + response.Error(c, http.StatusUnauthorized, "unauthorized") + return + } + + user, err := h.auth.Me(c.Request.Context(), userID) + if err != nil { + fail(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"user": user}) +} + +// Logout 登出。 +// +// POST /api/auth/logout +// 无状态 JWT 服务端无需作废令牌,由客户端丢弃即可;保留接口以便将来扩展黑名单。 +func (h *AuthHandler) Logout(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/internal/handler/brand_handler.go b/internal/handler/brand_handler.go new file mode 100644 index 0000000..6219d20 --- /dev/null +++ b/internal/handler/brand_handler.go @@ -0,0 +1,56 @@ +package handler + +import ( + "net/http" + "strings" + + "fashionapi/internal/dto" + "fashionapi/internal/pkg/response" + "fashionapi/internal/service" + + "github.com/gin-gonic/gin" +) + +// BrandHandler 对外公开的品牌接口。 +type BrandHandler struct { + brands service.BrandService +} + +// NewBrandHandler 创建品牌 handler。 +func NewBrandHandler(brands service.BrandService) *BrandHandler { + return &BrandHandler{brands: brands} +} + +// List 品牌列表(A-Z 字母索引 / 搜索 / 分页)。 +// +// GET /api/public/brands +// +// 查询参数: +// - letter A-Z 字母;other 表示中文分桶;缺省返回全部拉丁字母开头的品牌 +// - keyword 品牌名 / 展示名模糊搜索 +// - page / size 分页,size 默认 200、上限 500 +// - only_with_articles 默认 1,只返回有走秀档案的品牌;传 0 返回全部 +// - featured 传 1 时只返回"代表品牌"精选集合 +// - metric 精选排名指标:images(默认)/ shows +// - limit 精选集合大小,默认 200、上限 500 +func (h *BrandHandler) List(c *gin.Context) { + q := dto.BrandQuery{ + Letter: strings.ToUpper(strings.TrimSpace(c.Query("letter"))), + Keyword: strings.TrimSpace(c.Query("keyword")), + Page: queryInt(c, "page", 1), + Size: queryInt(c, "size", 200), + OnlyWithArticles: queryBool(c, "only_with_articles", true), + Featured: queryBool(c, "featured", false), + Metric: strings.ToLower(strings.TrimSpace(c.Query("metric"))), + Limit: queryInt(c, "limit", 200), + Locale: parseLocale(c), + } + q.Normalize() + + list, total, err := h.brands.List(c.Request.Context(), q) + if err != nil { + fail(c, err) + return + } + response.Page(c, http.StatusOK, list, total, q.Page, q.Size) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 0000000..738dae5 --- /dev/null +++ b/internal/handler/handler.go @@ -0,0 +1,109 @@ +// Package handler HTTP 处理层。 +// +// 职责边界:只做三件事 —— 解析请求参数、调用 service、写出响应。 +// 不包含任何 SQL 与业务规则。 +package handler + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "fashionapi/internal/pkg/response" + "fashionapi/internal/service" + + "github.com/gin-gonic/gin" +) + +// fail 把 service 层错误翻译为 HTTP 响应。 +// +// 业务错误携带自己的状态码与文案,原样透传(保证与原项目逐字一致); +// 其余未预期错误统一按 500 处理,避免内部细节泄漏成不可控的响应。 +func fail(c *gin.Context, err error) { + var svcErr *service.Error + if errors.As(err, &svcErr) { + response.Error(c, svcErr.Status, svcErr.Message) + return + } + response.Error(c, http.StatusInternalServerError, err.Error()) +} + +// queryInt 读取整型查询参数,缺失或非法时返回默认值。 +func queryInt(c *gin.Context, key string, def int) int { + v := strings.TrimSpace(c.Query(key)) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} + +// queryBool 读取布尔查询参数。 +// +// def 为该参数缺失时的取值: +// - def=true(如 only_with_articles):只有显式传 0 / false 才关闭; +// - def=false(如 featured):只有显式传 1 / true 才开启。 +// +// 这两种语义都来自原项目,必须保持,否则前端 A-Z 索引的数据量会突变。 +func queryBool(c *gin.Context, key string, def bool) bool { + v := strings.ToLower(strings.TrimSpace(c.Query(key))) + if v == "" { + return def + } + if def { + return v != "0" && v != "false" + } + return v == "1" || v == "true" +} + +// parseUint32CSV 解析逗号分隔的正整数列表(如 brand_ids=1,2,3),忽略非法项。 +func parseUint32CSV(s string) []uint32 { + if strings.TrimSpace(s) == "" { + return nil + } + var out []uint32 + for _, part := range strings.Split(s, ",") { + if n, err := strconv.Atoi(strings.TrimSpace(part)); err == nil && n > 0 { + out = append(out, uint32(n)) + } + } + return out +} + +// parseIntSlice 把字符串数组解析为整型数组,忽略非法项。 +func parseIntSlice(in []string) []int { + if len(in) == 0 { + return nil + } + var out []int + for _, v := range in { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + out = append(out, n) + } + } + return out +} + +// parseLocale 解析请求语言,优先级:?locale=cn|en > Accept-Language > 默认 en。 +// 与前端 i18n/config.ts 的 LOCALES=['en','cn']、DEFAULT_LOCALE='en' 保持一致。 +func parseLocale(c *gin.Context) string { + if l := strings.ToLower(strings.TrimSpace(c.Query("locale"))); l == "cn" || l == "en" { + return l + } + if al := c.GetHeader("Accept-Language"); al != "" { + for _, part := range strings.Split(al, ",") { + code := strings.ToLower(strings.TrimSpace(strings.SplitN(part, ";", 2)[0])) + switch { + case code == "zh" || strings.HasPrefix(code, "zh-"): + return "cn" + case strings.HasPrefix(code, "en"): + return "en" + } + } + } + return "en" +} diff --git a/internal/handler/health_handler.go b/internal/handler/health_handler.go new file mode 100644 index 0000000..9675659 --- /dev/null +++ b/internal/handler/health_handler.go @@ -0,0 +1,18 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// HealthHandler 健康检查,供容器编排与反向代理探活使用。 +type HealthHandler struct{} + +// NewHealthHandler 创建健康检查 handler。 +func NewHealthHandler() *HealthHandler { return &HealthHandler{} } + +// Check GET /api/health +func (h *HealthHandler) Check(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} diff --git a/internal/handler/ssg_handler.go b/internal/handler/ssg_handler.go new file mode 100644 index 0000000..0c1eac2 --- /dev/null +++ b/internal/handler/ssg_handler.go @@ -0,0 +1,90 @@ +package handler + +import ( + "net/http" + "strings" + + "fashionapi/internal/dto" + "fashionapi/internal/pkg/response" + "fashionapi/internal/service" + + "github.com/gin-gonic/gin" +) + +// SSGHandler 承载「仅供构建期(astro build)使用的内部接口」。 +// +// 这些接口返回全量/全 ID 数据,理论上不应暴露给公网,因此: +// - 它们只挂在独立的内部端口(默认 8091,见 router.NewSSG)上; +// - 该端口由 docker-compose 绑定 127.0.0.1(回环),nginx 也绝不反代; +// - 复用现有 service 层逻辑,与对外公开接口保持同一份数据源与映射规则, +// 不会出现「构建接口与线上接口返回结构不一致」的坑。 +type SSGHandler struct { + brands service.BrandService + articles service.ArticleService +} + +// NewSSGHandler 创建 SSG handler。 +func NewSSGHandler(brands service.BrandService, articles service.ArticleService) *SSGHandler { + return &SSGHandler{brands: brands, articles: articles} +} + +// Popular 首页「热门品牌」区块数据(SSG 内部版)。 +// +// GET /api/ssg/brands/popular?limit=20 +// +// 与公开版逻辑完全一致(复用 service.BrandService.Popular),只是挪到了内部端口, +// 不再挂在对外公开的 /api/public 下。limit 默认 20、上限 100。 +func (h *SSGHandler) Popular(c *gin.Context) { + limit := queryInt(c, "limit", 20) + if limit < 1 || limit > 100 { + limit = 20 + } + list, err := h.brands.Popular(c.Request.Context(), limit, parseLocale(c)) + if err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, list) +} + +// Brands 品牌索引页(HBX 风格 A-Z)的 SSG 全量数据。 +// +// GET /api/ssg/brands?size=300&only_with_articles=1&featured=1 +// +// 直接复用 BrandQuery + service.BrandService.List,支持字母/搜索/精选/分页等全部参数, +// 返回 {data:[...]} 与公开列表同构,前端 brands.astro 无需任何适配。 +func (h *SSGHandler) Brands(c *gin.Context) { + q := dto.BrandQuery{ + Letter: strings.ToUpper(strings.TrimSpace(c.Query("letter"))), + Keyword: strings.TrimSpace(c.Query("keyword")), + Page: queryInt(c, "page", 1), + Size: queryInt(c, "size", 200), + OnlyWithArticles: queryBool(c, "only_with_articles", true), + Featured: queryBool(c, "featured", false), + Metric: strings.ToLower(strings.TrimSpace(c.Query("metric"))), + Limit: queryInt(c, "limit", 200), + Locale: parseLocale(c), + } + q.Normalize() + + list, total, err := h.brands.List(c.Request.Context(), q) + if err != nil { + fail(c, err) + return + } + response.Page(c, http.StatusOK, list, total, q.Page, q.Size) +} + +// ArticleIDs 供 SSG 的 getStaticPaths 枚举详情页路径用。 +// +// GET /api/ssg/articles/ids +// +// 只回 [id,...](升序),不带回封面/描述等大字段,比拉 size=500 再 .map(id) 省带宽。 +func (h *SSGHandler) ArticleIDs(c *gin.Context) { + ids, err := h.articles.IDs(c.Request.Context()) + if err != nil { + fail(c, err) + return + } + response.Data(c, http.StatusOK, ids) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..b1ef6d1 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,57 @@ +// Package middleware Gin 中间件集合。 +package middleware + +import ( + "net/http" + "strings" + + "fashionapi/internal/pkg/jwt" + "fashionapi/internal/pkg/response" + + "github.com/gin-gonic/gin" +) + +// gin.Context 中存放身份信息的键。 +const ( + ContextUserID = "userID" + ContextUsername = "username" +) + +// bearerPrefix Authorization 头的令牌前缀。 +const bearerPrefix = "Bearer " + +// Auth JWT 鉴权中间件:解析 Authorization: Bearer ,把身份写入 Context。 +// +// 令牌签发器由外部注入,避免像原实现那样在每个请求里重新加载一次配置。 +func Auth(manager *jwt.Manager) gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + if !strings.HasPrefix(header, bearerPrefix) { + response.AbortError(c, http.StatusUnauthorized, "missing token") + return + } + + claims, err := manager.Parse(strings.TrimPrefix(header, bearerPrefix)) + if err != nil { + response.AbortError(c, http.StatusUnauthorized, "invalid token") + return + } + + c.Set(ContextUserID, claims.UserID) + c.Set(ContextUsername, claims.Username) + c.Next() + } +} + +// UserIDFrom 从 Context 取出当前用户 id。 +func UserIDFrom(c *gin.Context) (uint32, bool) { + v, exists := c.Get(ContextUserID) + if !exists { + return 0, false + } + id, ok := v.(uint32) + if !ok || id == 0 { + return 0, false + } + return id, true +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..d7cf70e --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "net/http" + "strconv" + "strings" + + "fashionapi/internal/config" + + "github.com/gin-gonic/gin" +) + +// CORS 跨域中间件,规则来自 yml 配置。 +// +// allow_origins 为 ["*"] 时直接放行全部来源(与原项目行为一致); +// 配置为具体域名列表时,仅当请求 Origin 命中白名单才回写该 Origin, +// 这样生产环境可以收紧到指定域名而无需改代码。 +func CORS(cfg config.CORSConfig) gin.HandlerFunc { + allowAll := len(cfg.AllowOrigins) == 0 + allowed := make(map[string]struct{}, len(cfg.AllowOrigins)) + for _, o := range cfg.AllowOrigins { + if o == "*" { + allowAll = true + } + allowed[o] = struct{}{} + } + + methods := strings.Join(cfg.AllowMethods, ", ") + headers := strings.Join(cfg.AllowHeaders, ", ") + maxAge := strconv.Itoa(cfg.MaxAge) + + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + switch { + case allowAll: + c.Header("Access-Control-Allow-Origin", "*") + case origin != "": + if _, ok := allowed[origin]; ok { + c.Header("Access-Control-Allow-Origin", origin) + // 指定来源时必须声明 Vary,避免 CDN / 代理把响应错误地跨来源复用 + c.Header("Vary", "Origin") + } + } + c.Header("Access-Control-Allow-Methods", methods) + c.Header("Access-Control-Allow-Headers", headers) + c.Header("Access-Control-Max-Age", maxAge) + + // 预检请求直接结束,不进入业务逻辑 + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} diff --git a/internal/middleware/ssg_token.go b/internal/middleware/ssg_token.go new file mode 100644 index 0000000..478df19 --- /dev/null +++ b/internal/middleware/ssg_token.go @@ -0,0 +1,40 @@ +package middleware + +import ( + "net/http" + + "fashionapi/internal/pkg/response" + + "github.com/gin-gonic/gin" +) + +// SSGToken 是 SSG 内部端口的「防御纵深」鉴权中间件。 +// +// 设计取舍: +// - token 为空字符串时直接放行(noop)。因为 SSG 端口本身只绑在 127.0.0.1(回环), +// 外部网络根本连不进来,本地开发/单机构建无需令牌也能保证安全。 +// - token 非空时,请求必须携带 ?token= 或 X-SSG-Token: 头, +// 否则返回 401。用于「即便回环被意外暴露(如误绑 0.0.0.0)」时的最后一道闸。 +// +// 注意:该中间件只装在 SSG 内部引擎上,对外公开引擎(8090)从不装载, +// 因此不会给公网接口引入任何额外逻辑。 +func SSGToken(token string) gin.HandlerFunc { + // 未配置令牌:回环绑定已足够,直接放行。 + if token == "" { + return func(c *gin.Context) { c.Next() } + } + + return func(c *gin.Context) { + // 优先从查询参数取(astro build 的 node fetch 拼 URL 最方便), + // 其次从自定义请求头取(便于 curl / CI 手动调用)。 + got := c.Query("token") + if got == "" { + got = c.GetHeader("X-SSG-Token") + } + if got != token { + response.AbortError(c, http.StatusUnauthorized, "invalid or missing ssg token") + return + } + c.Next() + } +} diff --git a/internal/model/brand.go b/internal/model/brand.go new file mode 100644 index 0000000..df3985a --- /dev/null +++ b/internal/model/brand.go @@ -0,0 +1,21 @@ +package model + +// Brand 品牌表。 +// +// 注意:时间戳字段为 int unsigned 存储的 Unix 秒,不是 MySQL datetime, +// 迁移时保持原样,避免与既有数据不兼容。 +type Brand struct { + ID uint32 `gorm:"primaryKey;column:id" json:"id"` + CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` + UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` + Name string `gorm:"column:name" json:"name"` + ShowName string `gorm:"column:show_name" json:"show_name"` + // i18n 列(方案 A):name_en 为规范拉丁名,A-Z 排序/搜索统一基于它; + // name_cn 为中文名;二者皆为空的服务端按 locale 回落。 + NameEn string `gorm:"column:name_en" json:"name_en"` + NameCn string `gorm:"column:name_cn" json:"name_cn"` + IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` +} + +// TableName 指定表名。 +func (Brand) TableName() string { return "brand" } diff --git a/internal/model/runway.go b/internal/model/runway.go new file mode 100644 index 0000000..4f95490 --- /dev/null +++ b/internal/model/runway.go @@ -0,0 +1,56 @@ +package model + +// BrandRunway 走秀档案(对外称"文章")。 +type BrandRunway struct { + ID uint32 `gorm:"primaryKey;column:id" json:"id"` + Title string `gorm:"column:title" json:"title"` + Description string `gorm:"column:description" json:"description"` + // i18n 列(方案 A):*_en 为规范英文(当前数据全英文),*_cn 为中文; + // 服务端按 locale 选列,缺翻译回落另一语言。 + TitleEn string `gorm:"column:title_en" json:"title_en"` + TitleCn string `gorm:"column:title_cn" json:"title_cn"` + DescriptionEn string `gorm:"column:description_en" json:"description_en"` + DescriptionCn string `gorm:"column:description_cn" json:"description_cn"` + CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` + UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` + IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` + ImageCount uint16 `gorm:"column:image_count" json:"image_count"` + BrandID uint32 `gorm:"column:brand_id" json:"brand_id"` + Year uint16 `gorm:"column:year" json:"year"` + Cover string `gorm:"column:cover" json:"cover"` + SourceURL string `gorm:"column:source_url" json:"source_url"` + + // 分类字段:由 title 解析回填(历史数据迁移一次性写入,现已由外部 SQL 迁移维护) + CollectionType string `gorm:"column:collection_type;size:16;not null;default:''" json:"collection_type"` // rtw/menswear/couture/resort/pre_fall + Season string `gorm:"column:season;size:8" json:"season"` // spring/fall/null + SeasonCode string `gorm:"column:season_code;size:8;not null;default:''" json:"season_code"` // SS26/FW25/RES26/PF25 +} + +// TableName 指定表名。 +func (BrandRunway) TableName() string { return "brand_runway" } + +// RunwayRow 走秀档案的查询投影行。 +// +// 独立于 BrandRunway 定义,是为了承载 JOIN brand 得到的 brand_name —— +// 该字段不属于 brand_runway 表,混进实体会污染模型语义。 +type RunwayRow struct { + ID uint32 `gorm:"column:id"` + BrandID uint32 `gorm:"column:brand_id"` + Title string `gorm:"column:title"` + TitleEn string `gorm:"column:title_en"` + TitleCn string `gorm:"column:title_cn"` + Description string `gorm:"column:description"` + DescriptionEn string `gorm:"column:description_en"` + DescriptionCn string `gorm:"column:description_cn"` + Cover string `gorm:"column:cover"` + Year uint16 `gorm:"column:year"` + ImageCount uint16 `gorm:"column:image_count"` + CollectionType string `gorm:"column:collection_type"` + Season string `gorm:"column:season"` + SeasonCode string `gorm:"column:season_code"` + CreatedAt uint32 `gorm:"column:created_at"` + SourceURL string `gorm:"column:source_url"` + BrandName string `gorm:"column:brand_name"` + BrandNameEn string `gorm:"column:brand_name_en"` + BrandNameCn string `gorm:"column:brand_name_cn"` +} diff --git a/internal/model/runway_image.go b/internal/model/runway_image.go new file mode 100644 index 0000000..fe35afa --- /dev/null +++ b/internal/model/runway_image.go @@ -0,0 +1,17 @@ +package model + +// BrandRunwayImage 走秀图片。 +type BrandRunwayImage struct { + ID uint32 `gorm:"primaryKey;column:id" json:"id"` + CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` + UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` + IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` + Image string `gorm:"column:image" json:"image"` + RunwayID uint32 `gorm:"column:runway_id" json:"runway_id"` + BrandID uint32 `gorm:"column:brand_id" json:"brand_id"` + Name string `gorm:"column:name" json:"name"` + SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` // 拖拽排序用,由迁移脚本新增 +} + +// TableName 指定表名。 +func (BrandRunwayImage) TableName() string { return "brand_runway_images" } diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..b005964 --- /dev/null +++ b/internal/model/user.go @@ -0,0 +1,17 @@ +package model + +// User 注册用户(账号体系)。 +// +// PasswordHash 的 json tag 为 "-",确保密码哈希永远不会被序列化返回。 +type User struct { + ID uint32 `gorm:"primaryKey;column:id" json:"id"` + CreatedAt uint32 `gorm:"column:created_at" json:"created_at"` + UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"` + Username string `gorm:"column:username;size:64;uniqueIndex" json:"username"` + Email string `gorm:"column:email;size:191;uniqueIndex" json:"email"` + PasswordHash string `gorm:"column:password_hash;size:255" json:"-"` + IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"` +} + +// TableName 指定表名。 +func (User) TableName() string { return "users" } diff --git a/internal/pkg/hashid/hashid.go b/internal/pkg/hashid/hashid.go new file mode 100644 index 0000000..247919a --- /dev/null +++ b/internal/pkg/hashid/hashid.go @@ -0,0 +1,126 @@ +// Package hashid 把自增主键(uint32)编码为无序、URL 安全的短串, +// 用于公开接口对外暴露,避免爬虫按 1,2,3... 顺序枚举全部文章/品牌。 +// +// 设计要点: +// - 内部仍用数字主键,仅对外序列化时编码、入参时解码,DB 与内部逻辑完全不变。 +// - 编码基于 32-bit 平衡 Feistel 网络(密钥由部署盐值派生)+ base62, +// 是真实双射:Decode(Encode(n)) == n 严格成立,解码即可还原主键。 +// - 非顺序:相邻 id 的编码结果无规律,无法 +1 遍历;盐值不同编码结果不同。 +// - 零外部依赖;字母表 0-9a-zA-Z 全部 URL 安全。 +package hashid + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + "strings" +) + +const ( + alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + base = 62 + minLen = 8 +) + +var ( + keys [4]uint32 + inited bool +) + +// Init 用部署级盐值初始化混淆密钥。盐值为空时使用内置默认(仅防顺序枚举,不算安全)。 +// 必须在服务启动时调用一次(main.go 加载配置后)。 +func Init(secret string) { + if secret == "" { + secret = "fashion-archive-default-salt-change-me" + } + h := sha256.Sum256([]byte(secret)) + for i := 0; i < 4; i++ { + keys[i] = binary.BigEndian.Uint32(h[i*4 : i*4+4]) + } + inited = true +} + +func ensure() { + if !inited { + Init("") + } +} + +// feistel 32-bit 平衡 Feistel 网络。encrypt=true 加密,false 解密。 +// Feistel 网络的逆只需逆序执行轮函数,因此无论 round function 是否可逆都能精确还原。 +// 加密轮:f 作用于右半块,左下一 = 右、右下一 = 左 ^ f(右)。 +// 解密轮:f 作用于左半块(解密时左半块即上一轮的右半块),右下一 = 左、左下一 = 右 ^ f(左)。 +func feistel(v uint32, encrypt bool) uint32 { + const rounds = 8 + l, r := uint16(v>>16), uint16(v&0xffff) + for i := 0; i < rounds; i++ { + idx := i + if !encrypt { + idx = rounds - 1 - i + } + // round function:乘法扩散 + 密钥混合 + 高地位混淆,输出取低 16 位 + round := func(h uint16) uint16 { + f := uint32(h)*0x9E3779B1 + keys[idx%4] + return uint16((f ^ (f >> 16)) & 0xffff) + } + if encrypt { + nl := r + nr := l ^ round(r) + l, r = nl, nr + } else { + nl := r ^ round(l) + nr := l + l, r = nl, nr + } + } + return uint32(l)<<16 | uint32(r) +} + +func encodeNum(n uint32) string { + x := feistel(n, true) + var sb strings.Builder + for x > 0 { + sb.WriteByte(alphabet[x%base]) + x /= base + } + if sb.Len() == 0 { + sb.WriteByte(alphabet[0]) + } + // base62 低位在前,反转成高位在前 + runes := []rune(sb.String()) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + out := string(runes) + if len(out) < minLen { + out = strings.Repeat("0", minLen-len(out)) + out + } + return out +} + +func decodeStr(s string) (uint32, error) { + var x uint32 + for _, c := range s { + idx := strings.IndexRune(alphabet, c) + if idx < 0 { + return 0, errors.New("invalid hashid: 含非法字符") + } + x = x*base + uint32(idx) + } + return feistel(x, false), nil +} + +// Encode 把数字主键编码为对外暴露的无序串。 +func Encode(id uint32) string { + ensure() + return encodeNum(id) +} + +// Decode 把对外串还原为数字主键;非法串返回 error(调用方应视为 404/未找到)。 +func Decode(s string) (uint32, error) { + ensure() + if strings.TrimSpace(s) == "" { + return 0, errors.New("empty hashid") + } + return decodeStr(strings.TrimSpace(s)) +} diff --git a/internal/pkg/hashid/hashid_test.go b/internal/pkg/hashid/hashid_test.go new file mode 100644 index 0000000..5b5c602 --- /dev/null +++ b/internal/pkg/hashid/hashid_test.go @@ -0,0 +1,42 @@ +package hashid + +import "testing" + +func TestRoundtrip(t *testing.T) { + // 覆盖边界与大量随机值,验证 Decode(Encode(n)) == n 严格成立 + cases := []uint32{0, 1, 2, 3, 7, 15, 16, 255, 256, 4095, 4096, 65535, 65536, 1 << 31, (1 << 32) - 1} + for _, n := range cases { + got, err := Decode(Encode(n)) + if err != nil { + t.Fatalf("Decode(Encode(%d)) 返回错误: %v", n, err) + } + if got != n { + t.Fatalf("往返不一致: 输入 %d, 得到 %d", n, got) + } + } + + // 随机大批量 + for n := uint32(1); n < 5000; n++ { + got, err := Decode(Encode(n)) + if err != nil || got != n { + t.Fatalf("往返不一致: 输入 %d, 得到 %d (err=%v)", n, got, err) + } + } +} + +func TestNonSequential(t *testing.T) { + Init("test-salt") + a, b, c := Encode(100), Encode(101), Encode(102) + if a == b || b == c || a == c { + t.Fatalf("相邻 id 编码结果出现了相等: %s %s %s", a, b, c) + } +} + +func TestInvalid(t *testing.T) { + if _, err := Decode(""); err == nil { + t.Fatal("空串应返回错误") + } + if _, err := Decode("!!!"); err == nil { + t.Fatal("含非法字符应返回错误") + } +} diff --git a/internal/pkg/jwt/jwt.go b/internal/pkg/jwt/jwt.go new file mode 100644 index 0000000..fe01a87 --- /dev/null +++ b/internal/pkg/jwt/jwt.go @@ -0,0 +1,82 @@ +// Package jwt 封装 JWT 令牌的签发与解析。 +// +// Claims 结构(uid / username / email / exp)与签名算法(HS256)保持与原项目一致, +// 因此原后端签发的 token 在本服务中依然有效(相同 secret 前提下)。 +package jwt + +import ( + "errors" + "time" + + jwtlib "github.com/golang-jwt/jwt/v5" +) + +// ErrInvalidToken 表示令牌缺失、过期或签名不合法。 +var ErrInvalidToken = errors.New("invalid token") + +// Claims 从令牌中解析出的用户身份信息。 +type Claims struct { + UserID uint32 + Username string + Email string +} + +// Manager 令牌签发器。通过构造函数注入密钥与有效期,避免每次调用都重新读取配置 +// (原项目在 genToken/中间件里各自调用 config.Load(),属于重复解析)。 +type Manager struct { + secret []byte + expire time.Duration +} + +// NewManager 创建令牌签发器。expireHours <= 0 时回落为 7 天。 +func NewManager(secret string, expireHours int) *Manager { + if expireHours <= 0 { + expireHours = 168 + } + return &Manager{ + secret: []byte(secret), + expire: time.Duration(expireHours) * time.Hour, + } +} + +// Generate 签发令牌。 +func (m *Manager) Generate(userID uint32, username, email string) (string, error) { + claims := jwtlib.MapClaims{ + "uid": userID, + "username": username, + "email": email, + "exp": time.Now().Add(m.expire).Unix(), + } + return jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims).SignedString(m.secret) +} + +// Parse 校验并解析令牌。 +func (m *Manager) Parse(tokenStr string) (*Claims, error) { + token, err := jwtlib.Parse(tokenStr, func(t *jwtlib.Token) (any, error) { + // 只接受 HMAC 签名,防止 alg 混淆攻击 + if _, ok := t.Method.(*jwtlib.SigningMethodHMAC); !ok { + return nil, jwtlib.ErrSignatureInvalid + } + return m.secret, nil + }) + if err != nil || !token.Valid { + return nil, ErrInvalidToken + } + raw, ok := token.Claims.(jwtlib.MapClaims) + if !ok { + return nil, ErrInvalidToken + } + + c := &Claims{} + // JSON 数字统一解析为 float64 + if uid, ok := raw["uid"].(float64); ok { + c.UserID = uint32(uid) + } + if v, ok := raw["username"].(string); ok { + c.Username = v + } + if v, ok := raw["email"].(string); ok { + c.Email = v + } + return c, nil +} diff --git a/internal/pkg/response/response.go b/internal/pkg/response/response.go new file mode 100644 index 0000000..c4c591f --- /dev/null +++ b/internal/pkg/response/response.go @@ -0,0 +1,52 @@ +// Package response 统一 HTTP 响应结构。 +// +// 响应形状严格沿用原项目,前端 (src/lib/api.ts、data.ts) 依赖这些字段名: +// - 列表:{ data, total, current_page, last_page, per_page } +// - 详情:{ data } +// - 错误:{ error } +// +// 集中在此处定义,避免各 handler 手写 gin.H 时出现字段拼写漂移。 +package response + +import "github.com/gin-gonic/gin" + +// Data 返回单个对象:{ "data": v }。 +func Data(c *gin.Context, status int, v any) { + c.JSON(status, gin.H{"data": v}) +} + +// Page 返回分页列表:{ data, total, current_page, last_page, per_page }。 +func Page(c *gin.Context, status int, data any, total int64, page, size int) { + c.JSON(status, gin.H{ + "data": data, + "total": total, + "current_page": page, + "last_page": LastPage(total, size), + "per_page": size, + }) +} + +// Error 返回错误:{ "error": msg }。 +func Error(c *gin.Context, status int, msg string) { + c.JSON(status, gin.H{"error": msg}) +} + +// AbortError 中断请求并返回错误,供中间件使用。 +func AbortError(c *gin.Context, status int, msg string) { + c.AbortWithStatusJSON(status, gin.H{"error": msg}) +} + +// LastPage 计算总页数;无数据时返回 1(与原项目一致,避免前端出现 0 页)。 +func LastPage(total int64, size int) int { + if size <= 0 { + return 1 + } + last := int(total) / size + if int(total)%size > 0 { + last++ + } + if last == 0 { + last = 1 + } + return last +} diff --git a/internal/pkg/textutil/textutil.go b/internal/pkg/textutil/textutil.go new file mode 100644 index 0000000..70164ea --- /dev/null +++ b/internal/pkg/textutil/textutil.go @@ -0,0 +1,13 @@ +// Package textutil 提供文本处理辅助函数。 +package textutil + +// Summarize 按 rune 截断字符串,避免中文被按字节切断;超出部分追加省略号。 +// +// 行为与原项目 handlers.summarize 完全一致(含省略号字符 U+2026)。 +func Summarize(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "…" +} diff --git a/internal/repository/article_repository.go b/internal/repository/article_repository.go new file mode 100644 index 0000000..9fd3ba5 --- /dev/null +++ b/internal/repository/article_repository.go @@ -0,0 +1,208 @@ +package repository + +import ( + "context" + "errors" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + + "gorm.io/gorm" +) + +// ArticleRepository 走秀档案(文章)数据访问接口。 +type ArticleRepository interface { + // List 按筛选条件分页查询文章,同时返回符合条件的总数。 + List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) + // FindByID 查询单篇文章(含 JOIN 出的品牌名)。不存在时返回 ErrNotFound。 + FindByID(ctx context.Context, id string) (*model.RunwayRow, error) + // ListImages 查询某篇文章的全部图片,按排序值升序。 + ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) + // ImagesByRunwayIDs 批量查询多篇文章的图片,单次 IN 查询避免 N+1。 + ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error) + // IDs 返回全部未删除文章的 id(升序),供 SSG 的 getStaticPaths 枚举路径使用。 + // 只回主键,不带回封面/描述等大字段,比拉 size=500 再 .map(id) 省得多。 + IDs(ctx context.Context) ([]uint32, error) +} + +type articleRepository struct { + db *gorm.DB +} + +// NewArticleRepository 创建文章仓储。 +func NewArticleRepository(db *gorm.DB) ArticleRepository { + return &articleRepository{db: db} +} + +// selectColumns 列表查询的投影列:只取对外需要的字段,不做 SELECT *。 +const articleListColumns = `brand_runway.id, brand_runway.brand_id, brand_runway.title, + brand_runway.title_en, brand_runway.title_cn, + brand_runway.description, brand_runway.description_en, brand_runway.description_cn, + brand_runway.cover, brand_runway.year, brand_runway.image_count, + brand_runway.created_at, brand_runway.collection_type, brand_runway.season, + brand_runway.season_code, b.name AS brand_name, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` + +const articleDetailColumns = `brand_runway.id, brand_runway.title, brand_runway.title_en, brand_runway.title_cn, + brand_runway.description, brand_runway.description_en, brand_runway.description_cn, + brand_runway.cover, brand_runway.year, brand_runway.image_count, brand_runway.created_at, + brand_runway.source_url, b.name AS brand_name, b.name_en AS brand_name_en, b.name_cn AS brand_name_cn` + +// brandJoin 关联品牌表取品牌名;LEFT JOIN 保证品牌被软删时文章依然可见。 +const brandJoin = "LEFT JOIN brand b ON b.id = brand_runway.brand_id AND b.is_deleted = 0" + +// filterScope 把查询条件编译为 GORM Scope。 +// +// 用 Scope 而非复用同一个 *gorm.DB:GORM v2 中在 Count 等终结方法之后复用同一实例 +// 会带上残留的 Statement 状态,Scope 每次作用于全新查询,杜绝这类隐患。 +func filterScope(q dto.ArticleQuery) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + db = db.Where("brand_runway.is_deleted = 0") + + if q.Keyword != "" { + kw := "%" + q.Keyword + "%" + db = db.Where("brand_runway.title_en LIKE ? OR brand_runway.title_cn LIKE ?", kw, kw) + } + if q.BrandID != "" && q.BrandID != "0" { + db = db.Where("brand_runway.brand_id = ?", q.BrandID) + } + // 多值筛选统一策略:单值用 = (命中索引更精准),多值用 IN,空集合不加条件。 + db = whereMulti(db, "brand_runway.brand_id", toAnySlice(q.BrandIDs)) + db = whereMulti(db, "brand_runway.collection_type", toAnySlice(q.CollectionTypes)) + db = whereMulti(db, "brand_runway.season", toAnySlice(q.Seasons)) + db = whereMulti(db, "brand_runway.year", toAnySlice(q.Years)) + + if q.SeasonCode != "" { + db = db.Where("brand_runway.season_code = ?", q.SeasonCode) + } + return db + } +} + +// whereMulti 按值数量选择 = 或 IN。 +func whereMulti(db *gorm.DB, column string, values []any) *gorm.DB { + switch len(values) { + case 0: + return db + case 1: + return db.Where(column+" = ?", values[0]) + default: + return db.Where(column+" IN ?", values) + } +} + +func toAnySlice[T any](in []T) []any { + if len(in) == 0 { + return nil + } + out := make([]any, 0, len(in)) + for _, v := range in { + out = append(out, v) + } + return out +} + +// orderBy 把 sort 参数映射为 ORDER BY 子句。 +// +// 白名单映射而非直接拼接用户输入,从根上排除 SQL 注入; +// 每个分支都以 created_at + id 兜底,保证分页结果稳定不跳行。 +func orderBy(sort string) string { + switch sort { + case "year_desc": + return "brand_runway.year DESC, brand_runway.created_at DESC, brand_runway.id DESC" + case "year_asc": + return "brand_runway.year ASC, brand_runway.created_at DESC, brand_runway.id DESC" + case "image_count": + return "brand_runway.image_count DESC, brand_runway.created_at DESC, brand_runway.id DESC" + default: // newest + return "brand_runway.created_at DESC, brand_runway.id DESC" + } +} + +func (r *articleRepository) List(ctx context.Context, q dto.ArticleQuery) ([]model.RunwayRow, int64, error) { + scope := filterScope(q) + + var total int64 + if err := r.db.WithContext(ctx). + Model(&model.BrandRunway{}). + Scopes(scope). + Count(&total).Error; err != nil { + return nil, 0, err + } + if total == 0 { + return []model.RunwayRow{}, 0, nil + } + + var rows []model.RunwayRow + if err := r.db.WithContext(ctx). + Model(&model.BrandRunway{}). + Scopes(scope). + Select(articleListColumns). + Joins(brandJoin). + Order(orderBy(q.Sort)). + Offset(q.Offset()). + Limit(q.Size). + Scan(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +func (r *articleRepository) FindByID(ctx context.Context, id string) (*model.RunwayRow, error) { + var row model.RunwayRow + err := r.db.WithContext(ctx). + Model(&model.BrandRunway{}). + Select(articleDetailColumns). + Joins(brandJoin). + Where("brand_runway.id = ? AND brand_runway.is_deleted = 0", id). + First(&row).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return &row, nil +} + +func (r *articleRepository) ListImages(ctx context.Context, runwayID string) ([]model.BrandRunwayImage, error) { + var imgs []model.BrandRunwayImage + err := r.db.WithContext(ctx). + Where("runway_id = ? AND is_deleted = 0", runwayID). + Order("sort_order ASC, id ASC"). + Find(&imgs).Error + return imgs, err +} + +func (r *articleRepository) ImagesByRunwayIDs(ctx context.Context, ids []uint32) (map[uint32][]model.BrandRunwayImage, error) { + result := make(map[uint32][]model.BrandRunwayImage, len(ids)) + if len(ids) == 0 { + return result, nil + } + + var imgs []model.BrandRunwayImage + err := r.db.WithContext(ctx). + Select("runway_id, image, name, sort_order"). + Where("runway_id IN ? AND is_deleted = 0", ids). + Order("sort_order ASC, id ASC"). + Find(&imgs).Error + if err != nil { + return nil, err + } + for _, im := range imgs { + result[im.RunwayID] = append(result[im.RunwayID], im) + } + return result, nil +} + +// IDs 返回全部未删除文章的 id(升序),供 SSG 构建期枚举详情页路径。 +func (r *articleRepository) IDs(ctx context.Context) ([]uint32, error) { + var ids []uint32 + if err := r.db.WithContext(ctx). + Model(&model.BrandRunway{}). + Where("is_deleted = 0"). + Order("id ASC"). + Pluck("id", &ids).Error; err != nil { + return nil, err + } + return ids, nil +} diff --git a/internal/repository/brand_repository.go b/internal/repository/brand_repository.go new file mode 100644 index 0000000..e5a5c31 --- /dev/null +++ b/internal/repository/brand_repository.go @@ -0,0 +1,292 @@ +package repository + +import ( + "context" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + + "gorm.io/gorm" +) + +// BrandRepository 品牌数据访问接口。 +type BrandRepository interface { + // List 按字母索引 / 关键词分页查询品牌。 + // + // restrictIDs 用于 featured 模式:非 nil 时结果被限制在这些品牌 id 内; + // 若 q.Featured 为 true 而 restrictIDs 为空,则返回空结果——绝不能退化成"全部品牌"。 + List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) + + // FeaturedIDs 返回"代表品牌"的 id 集合:在拥有走秀档案的品牌中, + // 按 metric(images = 图片总数 / shows = 发布会数)排名取前 limit 个。 + FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error) + + // ArticleCountByBrandIDs 批量统计各品牌的走秀档案数,单次分组查询避免 N+1。 + ArticleCountByBrandIDs(ctx context.Context, ids []uint32) (map[uint32]int, error) + + // PopularWithCover 返回"热门品牌"及其代表走秀的封面/标题/文章 ID。 + // 规则:先按品牌总图片数取前 limit 个热门品牌,再为每个品牌取最新一篇走秀。 + // 返回的是仓储层裸数据(数字主键),对外编码由 service 层负责。 + PopularWithCover(ctx context.Context, limit int) ([]PopularBrandRow, error) +} + +// PopularBrandRow 热门品牌裸数据:品牌数字主键 + 中英文名称 + 代表走秀封面/中英文标题/文章 ID。 +// 注意:数字主键不出仓储层,service 层经 hashid 编码后才对外暴露;i18n 选列由 service 按 locale 决定。 +type PopularBrandRow struct { + ID uint32 + ArticleID uint32 + BrandEn string + BrandCn string + TitleEn string + TitleCn string + Cover string +} + +type brandRepository struct { + db *gorm.DB +} + +// NewBrandRepository 创建品牌仓储。 +func NewBrandRepository(db *gorm.DB) BrandRepository { + return &brandRepository{db: db} +} + +// latinPrefixRegexp 匹配以拉丁字母开头的品牌名。 +// A-Z 索引语义要求排除中文 / 符号 / 引号开头的脏数据。 +// 排序与索引统一基于 name_en(拉丁规范名);name_en 为空的纯中文品牌归入 OTHER 桶。 +const latinPrefixRegexp = "^[A-Za-z]" + +// hasArticlesSubQuery 子查询:所有"至少有一篇走秀档案"的品牌 id。 +// 库中存在 2 万+ 无档案品牌,索引必须靠它收敛。 +func (r *brandRepository) hasArticlesSubQuery() *gorm.DB { + return r.db.Model(&model.BrandRunway{}). + Select("DISTINCT brand_id"). + Where("is_deleted = 0") +} + +// brandFilterScope 把品牌查询条件编译为 GORM Scope。 +func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint32) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + db = db.Where("is_deleted = 0") + + // 字母索引分桶(基于 name_en) + switch { + case q.Letter == "OTHER": + // 纯中文品牌:name_en 为空 + db = db.Where("name_en = ''") + case q.Letter >= "A" && q.Letter <= "Z" && len(q.Letter) == 1: + db = db.Where("UPPER(LEFT(name_en,1)) = ?", q.Letter) + default: + db = db.Where("name_en REGEXP ?", latinPrefixRegexp) + } + + // 中文分桶不限档案,直接列出全部中文名品牌,便于检索 + if q.OnlyWithArticles && q.Letter != "OTHER" { + db = db.Where("id IN (?)", r.hasArticlesSubQuery()) + } + + if q.Keyword != "" { + kw := "%" + q.Keyword + "%" + db = db.Where("name_en LIKE ? OR name_cn LIKE ?", kw, kw) + } + + // featured 模式:收敛到精选集合;取不到 id 就收窄为空集 + if q.Featured { + if len(restrictIDs) == 0 { + db = db.Where("1 = 0") + } else { + db = db.Where("id IN (?)", restrictIDs) + } + } + return db + } +} + +func (r *brandRepository) List(ctx context.Context, q dto.BrandQuery, restrictIDs []uint32) ([]model.Brand, int64, error) { + scope := r.brandFilterScope(q, restrictIDs) + + var total int64 + if err := r.db.WithContext(ctx). + Model(&model.Brand{}). + Scopes(scope). + Count(&total).Error; err != nil { + return nil, 0, err + } + if total == 0 { + return []model.Brand{}, 0, nil + } + + var items []model.Brand + if err := r.db.WithContext(ctx). + Model(&model.Brand{}). + Scopes(scope). + Order("name_en ASC"). + Offset(q.Offset()). + Limit(q.Size). + Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func (r *brandRepository) FeaturedIDs(ctx context.Context, metric string, limit int) ([]uint32, error) { + orderCol := "images" + if metric == "shows" { + orderCol = "shows" + } + + var agg []struct { + BrandID uint32 `gorm:"column:brand_id"` + } + err := r.db.WithContext(ctx). + Model(&model.BrandRunway{}). + Select("brand_id, COALESCE(SUM(image_count),0) AS images, COUNT(*) AS shows"). + Where("is_deleted = 0"). + Group("brand_id"). + Order(orderCol + " DESC"). + Limit(limit). + Scan(&agg).Error + if err != nil { + return nil, err + } + + ids := make([]uint32, 0, len(agg)) + for _, a := range agg { + ids = append(ids, a.BrandID) + } + return ids, nil +} + +func (r *brandRepository) ArticleCountByBrandIDs(ctx context.Context, ids []uint32) (map[uint32]int, error) { + counts := make(map[uint32]int, len(ids)) + if len(ids) == 0 { + return counts, nil + } + + var grp []struct { + BrandID uint32 `gorm:"column:brand_id"` + Cnt int `gorm:"column:cnt"` + } + err := r.db.WithContext(ctx). + Model(&model.BrandRunway{}). + Select("brand_id, COUNT(*) AS cnt"). + Where("brand_id IN ? AND is_deleted = 0", ids). + Group("brand_id"). + Scan(&grp).Error + if err != nil { + return nil, err + } + for _, g := range grp { + counts[g.BrandID] = g.Cnt + } + return counts, nil +} + +// PopularWithCover 返回"热门品牌"及其最新走秀的封面与标题。 +// +// 规则: +// 1. 先按品牌总图片数排名取前 limit 个"热门品牌"(复用 FeaturedIDs("images")) +// 2. 为每个热门品牌取最新一篇走秀(按真实季节年份:year DESC,season 优先级 FW>PF>SS>RES,并列取 id 最大) +// 3. 结果按热门品牌排名顺序返回 +// +// 注意:created_at/updated_at 是数据导入时间,不能代表走秀新旧,因此必须用 year + season_code。 +func (r *brandRepository) PopularWithCover(ctx context.Context, limit int) ([]PopularBrandRow, error) { + if limit < 1 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + // 1) 热门品牌 id(按总图片数排名) + ids, err := r.FeaturedIDs(ctx, "images", limit) + if err != nil { + return nil, err + } + if len(ids) == 0 { + return []PopularBrandRow{}, nil + } + + // 2) 为这些品牌各自取最新一篇走秀 + sql := ` +WITH latest_per_brand AS ( + SELECT id, brand_id, cover, title_en, title_cn, + ROW_NUMBER() OVER ( + PARTITION BY brand_id + ORDER BY + year DESC, + CASE + WHEN season_code LIKE 'FW%' THEN 4 + WHEN season_code LIKE 'PF%' THEN 3 + WHEN season_code LIKE 'SS%' THEN 2 + WHEN season_code LIKE 'RES%' THEN 1 + ELSE 0 + END DESC, + id DESC + ) AS rn + FROM brand_runway + WHERE is_deleted = 0 AND brand_id IN (?) +) +SELECT id, brand_id, cover, title_en, title_cn +FROM latest_per_brand +WHERE rn = 1` + + var rows []struct { + ID uint32 `gorm:"column:id"` + BrandID uint32 `gorm:"column:brand_id"` + Cover string `gorm:"column:cover"` + TitleEn string `gorm:"column:title_en"` + TitleCn string `gorm:"column:title_cn"` + } + if err := r.db.WithContext(ctx).Raw(sql, ids).Scan(&rows).Error; err != nil { + return nil, err + } + + // 3) 取品牌中英文名称 + nameMap := make(map[uint32]struct{ En, Cn string }, len(ids)) + { + var brands []model.Brand + if err := r.db.WithContext(ctx). + Where("id IN ? AND is_deleted = 0", ids).Find(&brands).Error; err != nil { + return nil, err + } + for _, b := range brands { + nameMap[b.ID] = struct{ En, Cn string }{b.NameEn, b.NameCn} + } + } + + // 4) 按热度排名顺序(ids)组装,保证前端展示顺序与热门排名一致 + latestByBrand := make(map[uint32]struct { + ArticleID uint32 + Cover string + TitleEn string + TitleCn string + }, len(rows)) + for _, rw := range rows { + latestByBrand[rw.BrandID] = struct { + ArticleID uint32 + Cover string + TitleEn string + TitleCn string + }{ArticleID: rw.ID, Cover: rw.Cover, TitleEn: rw.TitleEn, TitleCn: rw.TitleCn} + } + + list := make([]PopularBrandRow, 0, len(ids)) + for _, id := range ids { + l, ok := latestByBrand[id] + if !ok { + continue + } + nm := nameMap[id] + list = append(list, PopularBrandRow{ + ID: id, + ArticleID: l.ArticleID, + BrandEn: nm.En, + BrandCn: nm.Cn, + TitleEn: l.TitleEn, + TitleCn: l.TitleCn, + Cover: l.Cover, + }) + } + return list, nil +} diff --git a/internal/repository/errors.go b/internal/repository/errors.go new file mode 100644 index 0000000..af955e2 --- /dev/null +++ b/internal/repository/errors.go @@ -0,0 +1,12 @@ +// Package repository 数据访问层:封装全部 SQL / GORM 细节。 +// +// 分层约定: +// - 只有本层可以感知 *gorm.DB 与表结构;service 与 handler 不得直接触碰数据库。 +// - 对外只暴露接口 + 构造函数,便于在测试中替换实现。 +// - 数据库错误在此层归一化为本包的 sentinel error,避免 gorm 错误类型向上层泄漏。 +package repository + +import "errors" + +// ErrNotFound 记录不存在。 +var ErrNotFound = errors.New("record not found") diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go new file mode 100644 index 0000000..ccdfb3d --- /dev/null +++ b/internal/repository/user_repository.go @@ -0,0 +1,71 @@ +package repository + +import ( + "context" + "errors" + + "fashionapi/internal/model" + + "gorm.io/gorm" +) + +// UserRepository 用户数据访问接口。 +type UserRepository interface { + // ExistsByUsernameOrEmail 判断用户名或邮箱是否已被占用。 + ExistsByUsernameOrEmail(ctx context.Context, username, email string) (bool, error) + // Create 创建用户。 + Create(ctx context.Context, u *model.User) error + // FindByAccount 按邮箱或用户名查找未删除的用户。不存在时返回 ErrNotFound。 + FindByAccount(ctx context.Context, account string) (*model.User, error) + // FindByID 按主键查找用户。不存在时返回 ErrNotFound。 + FindByID(ctx context.Context, id uint32) (*model.User, error) +} + +type userRepository struct { + db *gorm.DB +} + +// NewUserRepository 创建用户仓储。 +func NewUserRepository(db *gorm.DB) UserRepository { + return &userRepository{db: db} +} + +func (r *userRepository) ExistsByUsernameOrEmail(ctx context.Context, username, email string) (bool, error) { + var cnt int64 + err := r.db.WithContext(ctx). + Model(&model.User{}). + Where("username = ? OR email = ?", username, email). + Count(&cnt).Error + if err != nil { + return false, err + } + return cnt > 0, nil +} + +func (r *userRepository) Create(ctx context.Context, u *model.User) error { + return r.db.WithContext(ctx).Create(u).Error +} + +func (r *userRepository) FindByAccount(ctx context.Context, account string) (*model.User, error) { + var u model.User + err := r.db.WithContext(ctx). + Where("is_deleted = 0 AND (email = ? OR username = ?)", account, account). + First(&u).Error + return wrapUser(&u, err) +} + +func (r *userRepository) FindByID(ctx context.Context, id uint32) (*model.User, error) { + var u model.User + err := r.db.WithContext(ctx).First(&u, id).Error + return wrapUser(&u, err) +} + +func wrapUser(u *model.User, err error) (*model.User, error) { + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + return nil, err + } + return u, nil +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..89109c8 --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,63 @@ +// Package router 负责路由注册。 +// +// 所有 URL 的唯一定义处 —— 想知道服务暴露了哪些接口,只看这个文件即可。 +package router + +import ( + "fashionapi/internal/config" + "fashionapi/internal/handler" + "fashionapi/internal/middleware" + "fashionapi/internal/pkg/jwt" + + "github.com/gin-gonic/gin" +) + +// Options 路由注册所需的全部依赖,由 main 装配后传入。 +type Options struct { + Config *config.Config + JWT *jwt.Manager + Article *handler.ArticleHandler + Brand *handler.BrandHandler + Auth *handler.AuthHandler + Health *handler.HealthHandler + SSG *handler.SSGHandler +} + +// New 构建 Gin 引擎并注册全部对外路由。 +// +// 注意:SSG 内部接口(/api/ssg/*)不在这里注册,而是由 NewSSG 挂在独立的内部端口上, +// 二者物理隔离,确保构建期全量数据不会从对外公开端口泄露。 +func New(opt Options) *gin.Engine { + r := gin.Default() + r.Use(middleware.CORS(opt.Config.CORS)) + + // 上传图片的静态服务。 + // 接口返回的 cover / image 是相对路径(如 /uploads/2026/08/xx.jpg), + // 前端用 toAbs() 拼上 base 后直接访问,因此这里必须对外提供静态文件。 + r.Static(opt.Config.Upload.URLPrefix, opt.Config.Upload.Dir) + + api := r.Group("/api") + { + // 健康检查,供容器 / 反向代理探活 + api.GET("/health", opt.Health.Check) + + // 对外公开接口:只读查询,不暴露任何后台管理字段 + public := api.Group("/public") + { + public.GET("/articles", opt.Article.List) + public.GET("/articles/:id", opt.Article.Detail) + public.GET("/brands", opt.Brand.List) + } + + // 账号体系:注册 / 登录 / 登出 / 当前用户(JWT 无状态) + auth := api.Group("/auth") + { + auth.POST("/register", opt.Auth.Register) + auth.POST("/login", opt.Auth.Login) + auth.POST("/logout", opt.Auth.Logout) + auth.GET("/me", middleware.Auth(opt.JWT), opt.Auth.Me) + } + } + + return r +} diff --git a/internal/router/ssg.go b/internal/router/ssg.go new file mode 100644 index 0000000..70a80aa --- /dev/null +++ b/internal/router/ssg.go @@ -0,0 +1,72 @@ +package router + +import ( + "fmt" + "net/url" + "time" + + "fashionapi/internal/middleware" + + "github.com/gin-gonic/gin" +) + +// NewSSG 构建「仅供构建期使用的内部引擎」,并返回它。 +// +// 与 New()(对外公开引擎,挂在 8090)的关键区别: +// 1. 不装载 CORS 中间件 —— SSG 是 server-to-server 的 node fetch 调用,CORS 是浏览器机制,用不上,反而减小攻击面; +// 2. 装载 SSGToken 中间件 —— 若配置了 SSG_TOKEN 则强制校验(防御纵深); +// 3. 只注册 /api/ssg/* 路由 —— 全量/全 ID 这类「构建专用」数据只在这里出现; +// 4. 该引擎由 main 监听在独立的内部端口(默认 8091),docker-compose 把它绑定到 127.0.0.1, +// nginx 也绝不反代,因此外部网络根本连不到,SSG 数据不会外泄。 +// +// 这样即使有人从公网域名探测 /api/ssg/*,请求只会打到 nginx(80)或公开引擎(8090), +// 二者都没有该路由,自然 404 / 由 nginx 拦截,数据永不出口。 +func NewSSG(opt Options) *gin.Engine { + r := gin.New() + r.Use(gin.Recovery()) + r.Use(ssgLogger()) + r.Use(middleware.SSGToken(opt.Config.Server.SSGToken)) + + ssg := r.Group("/api/ssg") + { + ssg.GET("/brands", opt.SSG.Brands) // 品牌索引页全量数据(A-Z) + ssg.GET("/brands/popular", opt.SSG.Popular) // 首页热门品牌区块 + ssg.GET("/articles/ids", opt.SSG.ArticleIDs) // 文章详情页路径枚举(getStaticPaths) + } + + return r +} + +// ssgLogger 输出与公开引擎一致的请求日志,但会把 query string 里的 token 脱敏为 ***, +// 避免 SSG_TOKEN 在日志里明文泄露。 +func ssgLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + rawQuery := c.Request.URL.RawQuery + if rawQuery != "" { + if q, err := url.ParseQuery(rawQuery); err == nil && q.Get("token") != "" { + q.Set("token", "***") + rawQuery = q.Encode() + } + } + + c.Next() + + latency := time.Since(start) + queryPart := "" + if rawQuery != "" { + queryPart = "?" + rawQuery + } + fmt.Fprintf(gin.DefaultWriter, + "[GIN] %s | %3d | %13v | %15s | %-7s %s%s\n", + time.Now().Format("2006/01/02 - 15:04:05"), + c.Writer.Status(), + latency, + c.ClientIP(), + c.Request.Method, + path, + queryPart, + ) + } +} diff --git a/internal/service/article_service.go b/internal/service/article_service.go new file mode 100644 index 0000000..624e33f --- /dev/null +++ b/internal/service/article_service.go @@ -0,0 +1,146 @@ +package service + +import ( + "context" + "errors" + "net/http" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + "fashionapi/internal/pkg/hashid" + "fashionapi/internal/pkg/textutil" + "fashionapi/internal/repository" +) + +// summaryLength 对外摘要的截断长度(rune 数),与原项目一致。 +const summaryLength = 140 + +// ArticleService 文章业务接口。 +type ArticleService interface { + List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error) + Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) + // IDs 返回全部文章的编码 uid,供 SSG 路径枚举(getStaticPaths)。 + IDs(ctx context.Context) ([]string, error) +} + +type articleService struct { + articles repository.ArticleRepository +} + +// NewArticleService 创建文章服务。 +func NewArticleService(articles repository.ArticleRepository) ArticleService { + return &articleService{articles: articles} +} + +// List 查询文章列表,并按需附带每篇的前 N 张图。 +func (s *articleService) List(ctx context.Context, q dto.ArticleQuery) ([]dto.PublicArticle, int64, error) { + q.Normalize() + + rows, total, err := s.articles.List(ctx, q) + if err != nil { + return nil, 0, internalErr(err.Error()) + } + + list := make([]dto.PublicArticle, 0, len(rows)) + ids := make([]uint32, 0, len(rows)) + for _, r := range rows { + ids = append(ids, r.ID) + list = append(list, dto.PublicArticle{ + UID: hashid.Encode(r.ID), + BrandUID: hashid.Encode(r.BrandID), + Title: pickLocale(r.TitleEn, r.TitleCn, q.Locale), + Summary: textutil.Summarize(pickLocale(r.DescriptionEn, r.DescriptionCn, q.Locale), summaryLength), + Cover: r.Cover, + BrandName: pickLocale(r.BrandNameEn, r.BrandNameCn, q.Locale), + Year: r.Year, + ImageCount: r.ImageCount, + CollectionType: r.CollectionType, + Season: r.Season, + SeasonCode: r.SeasonCode, + PublishedAt: r.CreatedAt, + }) + } + + if q.WithImages > 0 && len(list) > 0 { + if err := s.attachImages(ctx, list, ids, q.WithImages); err != nil { + return nil, 0, internalErr(err.Error()) + } + } + return list, total, nil +} + +// attachImages 用单次 IN 查询为列表批量补齐图片,避免前端逐篇请求详情。 +// ids 是与之对应的数字主键(对外已编码为 UID,此处用数字主键查库)。 +func (s *articleService) attachImages(ctx context.Context, list []dto.PublicArticle, ids []uint32, limit int) error { + grouped, err := s.articles.ImagesByRunwayIDs(ctx, ids) + if err != nil { + return err + } + for i := range list { + imgs, ok := grouped[ids[i]] + if !ok { + continue + } + if len(imgs) > limit { + imgs = imgs[:limit] + } + list[i].Images = toPublicImages(imgs) + } + return nil +} + +// Detail 查询文章详情,含完整图片集。locale 决定标题/描述/品牌名的展示语言。 +func (s *articleService) Detail(ctx context.Context, id string, locale string) (*dto.PublicArticleDetail, error) { + row, err := s.articles.FindByID(ctx, id) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, ErrArticleNotFound + } + return nil, NewError(http.StatusInternalServerError, err.Error()) + } + + imgs, err := s.articles.ListImages(ctx, id) + if err != nil { + return nil, internalErr(err.Error()) + } + + return &dto.PublicArticleDetail{ + UID: hashid.Encode(row.ID), + BrandUID: hashid.Encode(row.BrandID), + Title: pickLocale(row.TitleEn, row.TitleCn, locale), + Summary: textutil.Summarize(pickLocale(row.DescriptionEn, row.DescriptionCn, locale), summaryLength), + Description: pickLocale(row.DescriptionEn, row.DescriptionCn, locale), + Cover: row.Cover, + BrandName: pickLocale(row.BrandNameEn, row.BrandNameCn, locale), + Year: row.Year, + ImageCount: row.ImageCount, + SourceURL: row.SourceURL, + PublishedAt: row.CreatedAt, + Images: toPublicImages(imgs), + }, nil +} + +// IDs 返回全部文章的编码 uid(供 SSG 枚举详情页路径使用,对外暴露的即编码串)。 +func (s *articleService) IDs(ctx context.Context) ([]string, error) { + ids, err := s.articles.IDs(ctx) + if err != nil { + return nil, internalErr(err.Error()) + } + out := make([]string, 0, len(ids)) + for _, id := range ids { + out = append(out, hashid.Encode(id)) + } + return out, nil +} + +// toPublicImages 把图片实体映射为对外结构(只保留 url 与名称)。 +func toPublicImages(imgs []model.BrandRunwayImage) []dto.PublicArticleImage { + if len(imgs) == 0 { + return nil + } + out := make([]dto.PublicArticleImage, 0, len(imgs)) + for _, im := range imgs { + out = append(out, dto.PublicArticleImage{Image: im.Image, Name: im.Name}) + } + return out +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go new file mode 100644 index 0000000..9def948 --- /dev/null +++ b/internal/service/auth_service.go @@ -0,0 +1,117 @@ +package service + +import ( + "context" + "errors" + "regexp" + "strings" + + "fashionapi/internal/dto" + "fashionapi/internal/model" + "fashionapi/internal/pkg/jwt" + "fashionapi/internal/repository" + + "golang.org/x/crypto/bcrypt" +) + +// emailRegex 邮箱格式校验,与原项目一致。 +var emailRegex = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + +// minPasswordLength 密码最小长度。 +const minPasswordLength = 6 + +// AuthService 账号体系业务接口。 +type AuthService interface { + Register(ctx context.Context, req dto.AuthRequest) (*dto.AuthResult, error) + Login(ctx context.Context, req dto.AuthRequest) (*dto.AuthResult, error) + Me(ctx context.Context, userID uint32) (*dto.UserPayload, error) +} + +type authService struct { + users repository.UserRepository + jwt *jwt.Manager +} + +// NewAuthService 创建账号服务。 +func NewAuthService(users repository.UserRepository, jwtManager *jwt.Manager) AuthService { + return &authService{users: users, jwt: jwtManager} +} + +// Register 注册新用户,成功后直接签发令牌(免去前端再调一次登录)。 +func (s *authService) Register(ctx context.Context, req dto.AuthRequest) (*dto.AuthResult, error) { + username := strings.TrimSpace(req.Username) + email := strings.TrimSpace(req.Email) + password := req.Password + + if username == "" || email == "" || len(password) < minPasswordLength { + return nil, badRequest("username、email 与 password(>=6 位) 均为必填") + } + if !emailRegex.MatchString(email) { + return nil, badRequest("email 格式不正确") + } + + exists, err := s.users.ExistsByUsernameOrEmail(ctx, username, email) + if err != nil { + return nil, internalErr("server error") + } + if exists { + return nil, conflict("该用户名或邮箱已被注册") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, internalErr("server error") + } + + user := model.User{Username: username, Email: email, PasswordHash: string(hash)} + if err := s.users.Create(ctx, &user); err != nil { + return nil, internalErr("创建用户失败") + } + return s.issue(user) +} + +// Login 登录:account 可以是邮箱或用户名。 +func (s *authService) Login(ctx context.Context, req dto.AuthRequest) (*dto.AuthResult, error) { + account := strings.TrimSpace(req.Account) + password := req.Password + if account == "" || password == "" { + return nil, badRequest("账号与密码均为必填") + } + + user, err := s.users.FindByAccount(ctx, account) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + // 账号不存在与密码错误返回同一文案,避免账号枚举 + return nil, unauthorized("账号或密码错误") + } + return nil, internalErr("server error") + } + if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil { + return nil, unauthorized("账号或密码错误") + } + return s.issue(*user) +} + +// Me 用令牌中的 uid 取回最新用户信息。 +func (s *authService) Me(ctx context.Context, userID uint32) (*dto.UserPayload, error) { + user, err := s.users.FindByID(ctx, userID) + if err != nil { + // 令牌有效但用户已不存在,同样视为未授权 + return nil, unauthorized("unauthorized") + } + p := toUserPayload(*user) + return &p, nil +} + +// issue 签发令牌并组装返回体。 +func (s *authService) issue(u model.User) (*dto.AuthResult, error) { + token, err := s.jwt.Generate(u.ID, u.Username, u.Email) + if err != nil { + return nil, internalErr("server error") + } + return &dto.AuthResult{Token: token, User: toUserPayload(u)}, nil +} + +func toUserPayload(u model.User) dto.UserPayload { + return dto.UserPayload{ID: u.ID, Username: u.Username, Email: u.Email} +} diff --git a/internal/service/brand_service.go b/internal/service/brand_service.go new file mode 100644 index 0000000..b89ae01 --- /dev/null +++ b/internal/service/brand_service.go @@ -0,0 +1,91 @@ +package service + +import ( + "context" + + "fashionapi/internal/dto" + "fashionapi/internal/pkg/hashid" + "fashionapi/internal/repository" +) + +// BrandService 品牌业务接口。 +type BrandService interface { + List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) + // Popular 首页"热门品牌"区块:按图片数排名的前 limit 个品牌,附带代表走秀封面与标题。 + // locale 决定品牌名/标题的展示语言(cn/en)。 + Popular(ctx context.Context, limit int, locale string) ([]dto.PopularBrand, error) +} + +type brandService struct { + brands repository.BrandRepository +} + +// NewBrandService 创建品牌服务。 +func NewBrandService(brands repository.BrandRepository) BrandService { + return &brandService{brands: brands} +} + +// List 查询品牌列表并附带每个品牌的档案数。 +// +// featured=1 时先按指标算出"代表品牌"集合,再把列表收敛到该集合内 —— +// 全库 1600+ 有档案品牌对 A-Z 索引来说过多,精选前 200 更符合导航场景。 +func (s *brandService) List(ctx context.Context, q dto.BrandQuery) ([]dto.PublicBrand, int64, error) { + q.Normalize() + + var restrictIDs []uint32 + if q.Featured { + ids, err := s.brands.FeaturedIDs(ctx, q.Metric, q.Limit) + if err != nil { + // 取不到精选集合时交给 repository 收窄为空集, + // 绝不静默退化成"返回全部品牌"——那会让前端索引突然膨胀。 + restrictIDs = nil + } else { + restrictIDs = ids + } + } + + items, total, err := s.brands.List(ctx, q, restrictIDs) + if err != nil { + return nil, 0, internalErr(err.Error()) + } + + ids := make([]uint32, 0, len(items)) + for _, b := range items { + ids = append(ids, b.ID) + } + counts, err := s.brands.ArticleCountByBrandIDs(ctx, ids) + if err != nil { + return nil, 0, internalErr(err.Error()) + } + + list := make([]dto.PublicBrand, 0, len(items)) + for _, b := range items { + list = append(list, dto.PublicBrand{ + UID: hashid.Encode(b.ID), + Name: pickLocale(b.NameEn, b.NameCn, q.Locale), + ShowName: b.NameCn, + ArticleCount: counts[b.ID], + }) + } + return list, total, nil +} + +// Popular 热门品牌(首页区块用):按图片数排名的前 limit 个品牌,附带代表走秀封面与标题。 +// 复用仓储层的 FeaturedIDs 排名逻辑,保持与"代表品牌"一致的"热门"定义。locale 选展示语言。 +func (s *brandService) Popular(ctx context.Context, limit int, locale string) ([]dto.PopularBrand, error) { + rows, err := s.brands.PopularWithCover(ctx, limit) + if err != nil { + return nil, internalErr(err.Error()) + } + out := make([]dto.PopularBrand, 0, len(rows)) + for _, b := range rows { + out = append(out, dto.PopularBrand{ + UID: hashid.Encode(b.ID), + ArticleUID: hashid.Encode(b.ArticleID), + Brand: pickLocale(b.BrandEn, b.BrandCn, locale), + Cover: b.Cover, + Title: pickLocale(b.TitleEn, b.TitleCn, locale), + }) + } + return out, nil +} diff --git a/internal/service/errors.go b/internal/service/errors.go new file mode 100644 index 0000000..9c12fb6 --- /dev/null +++ b/internal/service/errors.go @@ -0,0 +1,34 @@ +// Package service 业务逻辑层。 +// +// 分层约定: +// - 只依赖 repository 接口,不感知 *gorm.DB,也不感知 *gin.Context; +// - 业务规则(摘要截断长度、精选品牌排名、密码强度、令牌签发)集中在这里, +// handler 只负责 HTTP 参数解析与响应写出。 +package service + +import "net/http" + +// Error 携带 HTTP 状态码的业务错误。 +// +// service 层不引入 gin 依赖,但错误的语义(是客户端参数错误还是服务端故障) +// 只有业务层清楚。用状态码 + 文案表达,handler 直接透传, +// 既保持了分层,又能让错误响应与原项目逐字一致。 +type Error struct { + Status int + Message string +} + +func (e *Error) Error() string { return e.Message } + +// NewError 构造业务错误。 +func NewError(status int, msg string) *Error { + return &Error{Status: status, Message: msg} +} + +func badRequest(msg string) *Error { return NewError(http.StatusBadRequest, msg) } +func unauthorized(msg string) *Error { return NewError(http.StatusUnauthorized, msg) } +func conflict(msg string) *Error { return NewError(http.StatusConflict, msg) } +func internalErr(msg string) *Error { return NewError(http.StatusInternalServerError, msg) } + +// ErrArticleNotFound 文章不存在,文案与原项目一致。 +var ErrArticleNotFound = NewError(http.StatusNotFound, "文章不存在") diff --git a/internal/service/locale.go b/internal/service/locale.go new file mode 100644 index 0000000..9987f4e --- /dev/null +++ b/internal/service/locale.go @@ -0,0 +1,33 @@ +package service + +// 默认语言与受支持语言(与前端 i18n/config.ts 的 LOCALES 保持一致)。 +const ( + defaultLocale = "en" + localeCN = "cn" + localeEN = "en" +) + +// normalizeLocale 把任意输入收敛为受支持的语言;非法或空回落默认 en。 +func normalizeLocale(locale string) string { + if locale == localeCN || locale == localeEN { + return locale + } + return defaultLocale +} + +// pickLocale 按 locale 选语言字段,缺翻译时回落另一语言,保证出参永远非空(除非两者皆空)。 +// +// 这是 i18n 选列的唯一入口:repository 返回 en/cn 两列裸数据,service 在此按 locale 收敛成 +// 对外单字段(name / title / description / brand_name),前端契约因此保持不变。 +func pickLocale(en, cn, locale string) string { + if normalizeLocale(locale) == localeCN { + if cn != "" { + return cn + } + return en + } + if en != "" { + return en + } + return cn +} diff --git a/scripts/fashionapi.service b/scripts/fashionapi.service new file mode 100644 index 0000000..2afbacc --- /dev/null +++ b/scripts/fashionapi.service @@ -0,0 +1,34 @@ +# ============================================================ +# Fashion API —— systemd 服务单元(可选,用于服务器常驻运行) +# ------------------------------------------------------------ +# 用法(在服务器上): +# sudo cp fashionapi.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable --now fashionapi +# 查看日志:sudo journalctl -u fashionapi -f +# +# 敏感项(JWT_SECRET / DB_PASSWORD 等)建议用 Environment 注入, +# 不要写进 config.yml,也不要提交到仓库。 +# ============================================================ + +[Unit] +Description=Fashion API (public readonly endpoints) +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/fashionapi +ExecStart=/opt/fashionapi/server-linux-amd64 +# 生产环境变量覆盖(按需修改) +Environment=CONFIG_PATH=/opt/fashionapi/config.yml +Environment=GIN_MODE=release +# Environment=DB_HOST=127.0.0.1 +# Environment=DB_PASSWORD=your_strong_password +# Environment=JWT_SECRET=change-me-to-a-random-long-string +Restart=on-failure +RestartSec=3 +# 如需指定运行账户,取消下一行注释并确保该用户对 /opt/fashionapi 有读权限 +# User=www-data + +[Install] +WantedBy=multi-user.target diff --git a/scripts/i18n/main.go b/scripts/i18n/main.go new file mode 100644 index 0000000..196a3a5 --- /dev/null +++ b/scripts/i18n/main.go @@ -0,0 +1,106 @@ +// Command i18n_migrate 落地方案 A 的 i18n 列(加列 + 回填)。 +// +// 项目已移除 AutoMigrate,表结构由外部 SQL 托管(见 scripts/sql/002_i18n_columns.sql, +// 那是给人 review 的规范底稿)。本程序是「没有 mysql 客户端时」的执行器: +// - ALTER 走 ensureColumn:先查 information_schema,缺才加,可安全重跑(幂等)。 +// - 回填 UPDATE 内联且幂等,重跑无害。 +// +// 不写任何业务逻辑。 +package main + +import ( + "fmt" + "log" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +const dsn = "root:root@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local" +const han = "[一-鿿]" + +func main() { + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatal("open:", err) + } + + // 1) 幂等加列(与 002_i18n_columns.sql 对齐) + ensureColumn(db, "brand", "name_en", "VARCHAR(255) NOT NULL DEFAULT '' AFTER name") + ensureColumn(db, "brand", "name_cn", "VARCHAR(255) NOT NULL DEFAULT '' AFTER name_en") + ensureColumn(db, "brand_runway", "title_en", "VARCHAR(255) NOT NULL DEFAULT '' AFTER title") + ensureColumn(db, "brand_runway", "title_cn", "VARCHAR(255) NOT NULL DEFAULT '' AFTER title_en") + ensureColumn(db, "brand_runway", "description_en", "VARCHAR(1024) NOT NULL DEFAULT '' AFTER description") + ensureColumn(db, "brand_runway", "description_cn", "VARCHAR(1024) NOT NULL DEFAULT '' AFTER description_en") + + // 2) 回填品牌名:从 name / show_name 拆拉丁与中文(幂等) + db.Exec(fmt.Sprintf(` + UPDATE brand SET + name_en = CASE + WHEN TRIM(REGEXP_REPLACE(name, '%s', '')) <> '' THEN TRIM(REGEXP_REPLACE(name, '%s', '')) + WHEN show_name LIKE '%%/%%' THEN TRIM(SUBSTRING_INDEX(show_name, '/', 1)) + ELSE '' + END, + name_cn = CASE + WHEN show_name LIKE '%%/%%' AND SUBSTRING_INDEX(show_name, '/', -1) REGEXP '%s' + THEN TRIM(SUBSTRING_INDEX(show_name, '/', -1)) + WHEN show_name REGEXP '%s' THEN TRIM(show_name) + WHEN name REGEXP '%s' THEN TRIM(name) + ELSE '' + END`, han, han, han, han, han)) + + // 3) 回填走秀:title / description 当前全英文,直接进 *_en(幂等) + db.Exec(`UPDATE brand_runway SET title_en = title, description_en = description, title_cn = '', description_cn = ''`) + + // 4) 校验 + printCounts(db) + fmt.Println("migration done.") +} + +func ensureColumn(db *gorm.DB, table, col, def string) { + var n int64 + db.Raw("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='db' AND TABLE_NAME=? AND COLUMN_NAME=?", table, col).Scan(&n) + if n > 0 { + fmt.Printf(" skip %s.%s (exists)\n", table, col) + return + } + stmt := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, def) + fmt.Println(" +", stmt) + if r := db.Exec(stmt); r.Error != nil { + log.Fatalf("alter %s.%s failed: %v", table, col, r.Error) + } +} + +func printCounts(db *gorm.DB) { + type c struct { + Tot, En, Cn, Both, Dcn int64 + } + var b c + db.Raw(`SELECT COUNT(*) tot, + SUM(CASE WHEN name_en<>'' THEN 1 ELSE 0 END) en, + SUM(CASE WHEN name_cn<>'' THEN 1 ELSE 0 END) cn, + SUM(CASE WHEN name_en<>'' AND name_cn<>'' THEN 1 ELSE 0 END) both_cnt + FROM brand`).Scan(&b) + fmt.Printf(" brand: total=%d name_en=%d name_cn=%d both=%d\n", b.Tot, b.En, b.Cn, b.Both) + + var r c + db.Raw(`SELECT COUNT(*) tot, + SUM(CASE WHEN title_en<>'' THEN 1 ELSE 0 END) en, + SUM(CASE WHEN title_cn<>'' THEN 1 ELSE 0 END) cn, + SUM(CASE WHEN description_cn<>'' THEN 1 ELSE 0 END) dcn + FROM brand_runway WHERE is_deleted=0`).Scan(&r) + fmt.Printf(" runway: total=%d title_en=%d title_cn=%d desc_cn=%d\n", r.Tot, r.En, r.Cn, r.Dcn) + + type s struct { + Name string + ShowName string + NameEn string + NameCn string + } + var samples []s + db.Raw("SELECT name, show_name, name_en, name_cn FROM brand WHERE name REGEXP ? OR show_name REGEXP ? ORDER BY id LIMIT 8", han, han).Scan(&samples) + fmt.Println(" brand samples:") + for _, x := range samples { + fmt.Printf(" name=%-18q show=%-26q -> en=%-18q cn=%-18q\n", x.Name, x.ShowName, x.NameEn, x.NameCn) + } +} diff --git a/scripts/sql/002_i18n_columns.sql b/scripts/sql/002_i18n_columns.sql new file mode 100644 index 0000000..a169a0b --- /dev/null +++ b/scripts/sql/002_i18n_columns.sql @@ -0,0 +1,49 @@ +-- ============================================================================ +-- 002_i18n_columns.sql +-- 方案 A(独立列):把品牌名 / 走秀标题 / 描述 拆分为 en / cn 两列。 +-- +-- 设计前提(已用探查脚本核对线上数据): +-- * brand.name 多为拉丁名,但 266 个含中文;show_name 形如 "拉丁/中文"(2025 个含中文)。 +-- * brand_runway.title / description 校验过 han=0,当前全为英文。 +-- * 排序 / A-Z 索引 / 关键词搜索 统一基于 name_en(拉丁),中文品牌(name_en 为空)归入 OTHER 桶。 +-- +-- 回填策略: +-- name_en = name 去掉中文后的部分;若 name 全中文则取 show_name 斜杠前部分。 +-- name_cn = show_name 斜杠后含中文的部分;否则 show_name 本身(含中文);否则 name(含中文)。 +-- title_en / description_en = 原 title / description;title_cn / description_cn = ''(当前无中文)。 +-- +-- 旧列 name / show_name / title / description 保留不删,作为回退与对比; +-- 代码全部改用 *_en / *_cn 后,可择机在后续迁移中 DROP。 +-- ============================================================================ + +ALTER TABLE brand + ADD COLUMN name_en VARCHAR(255) NOT NULL DEFAULT '' AFTER name, + ADD COLUMN name_cn VARCHAR(255) NOT NULL DEFAULT '' AFTER name_en; + +ALTER TABLE brand_runway + ADD COLUMN title_en VARCHAR(255) NOT NULL DEFAULT '' AFTER title, + ADD COLUMN title_cn VARCHAR(255) NOT NULL DEFAULT '' AFTER title_en, + ADD COLUMN description_en VARCHAR(1024) NOT NULL DEFAULT '' AFTER description, + ADD COLUMN description_cn VARCHAR(1024) NOT NULL DEFAULT '' AFTER description_en; + +-- 回填品牌名:拆拉丁 / 中文 +UPDATE brand SET + name_en = CASE + WHEN TRIM(REGEXP_REPLACE(name, '[一-鿿]', '')) <> '' THEN TRIM(REGEXP_REPLACE(name, '[一-鿿]', '')) + WHEN show_name LIKE '%/%' THEN TRIM(SUBSTRING_INDEX(show_name, '/', 1)) + ELSE '' + END, + name_cn = CASE + WHEN show_name LIKE '%/%' AND SUBSTRING_INDEX(show_name, '/', -1) REGEXP '[一-鿿]' + THEN TRIM(SUBSTRING_INDEX(show_name, '/', -1)) + WHEN show_name REGEXP '[一-鿿]' THEN TRIM(show_name) + WHEN name REGEXP '[一-鿿]' THEN TRIM(name) + ELSE '' + END; + +-- 回填走秀:当前 title / description 全为英文,直接进 *_en +UPDATE brand_runway SET + title_en = title, + description_en = description, + title_cn = '', + description_cn = ''; diff --git a/scripts/sql/003_drop_brand_alias.sql b/scripts/sql/003_drop_brand_alias.sql new file mode 100644 index 0000000..19afb82 --- /dev/null +++ b/scripts/sql/003_drop_brand_alias.sql @@ -0,0 +1,8 @@ +-- 003_drop_brand_alias.sql +-- 删除 brand_alias 孤儿表。 +-- 背景:i18n 方案 A 落地后,brand.name_cn / name_en 已成为品牌名权威来源; +-- brand_alias 全代码库零引用,且 99.9% 中文记录与 brand.name_cn 完全重复, +-- 保留它只会成为名字的第二来源、后期悄然分叉。故删除。 +-- 注意:本文件为审阅底稿,实际执行由 scripts/i18n/drop_alias/main.go 驱动(无 mysql 客户端环境)。 + +DROP TABLE IF EXISTS brand_alias; diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..e69de29