This commit is contained in:
toom1996
2026-08-26 10:45:21 +08:00
parent 3cf1368e68
commit 30c9f21da4
48 changed files with 3442 additions and 0 deletions

25
.gitignore vendored Normal file
View File

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

27
Dockerfile Normal file
View File

@ -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"]

30
Makefile Normal file
View File

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

BIN
_server.exe Normal file

Binary file not shown.

63
build-linux.bat Normal file
View File

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

142
cmd/server/main.go Normal file
View File

@ -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("✓ 服务已停止")
}

63
configs/config.yml Normal file
View File

@ -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 # 预检请求缓存秒数

46
go.mod Normal file
View File

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

100
go.sum Normal file
View File

@ -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=

281
internal/config/config.go Normal file
View File

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

View File

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

98
internal/dto/article.go Normal file
View File

@ -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"`
}

30
internal/dto/auth.go Normal file
View File

@ -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"`
}

74
internal/dto/brand.go Normal file
View File

@ -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"` // 代表走秀标题
}

View File

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

View File

@ -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 <token>。
//
// 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})
}

View File

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

109
internal/handler/handler.go Normal file
View File

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

View File

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

View File

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

View File

@ -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 <token>,把身份写入 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
}

View File

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

View File

@ -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=<secret> 或 X-SSG-Token: <secret> 头,
// 否则返回 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()
}
}

21
internal/model/brand.go Normal file
View File

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

56
internal/model/runway.go Normal file
View File

@ -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"`
}

View File

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

17
internal/model/user.go Normal file
View File

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

View File

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

View File

@ -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("含非法字符应返回错误")
}
}

82
internal/pkg/jwt/jwt.go Normal file
View File

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

View File

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

View File

@ -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]) + "…"
}

View File

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

View File

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

View File

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

View File

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

63
internal/router/router.go Normal file
View File

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

72
internal/router/ssg.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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, "文章不存在")

View File

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

View File

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

106
scripts/i18n/main.go Normal file
View File

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

View File

@ -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 = '';

View File

@ -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;

0
uploads/.gitkeep Normal file
View File