update
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
27
Dockerfile
Normal 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
30
Makefile
Normal 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
BIN
_server.exe
Normal file
Binary file not shown.
63
build-linux.bat
Normal file
63
build-linux.bat
Normal 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
142
cmd/server/main.go
Normal 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
63
configs/config.yml
Normal 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
46
go.mod
Normal 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
100
go.sum
Normal 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
281
internal/config/config.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
internal/database/mysql.go
Normal file
67
internal/database/mysql.go
Normal 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
98
internal/dto/article.go
Normal 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
30
internal/dto/auth.go
Normal 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
74
internal/dto/brand.go
Normal 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"` // 代表走秀标题
|
||||||
|
}
|
||||||
120
internal/handler/article_handler.go
Normal file
120
internal/handler/article_handler.go
Normal 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
|
||||||
|
}
|
||||||
89
internal/handler/auth_handler.go
Normal file
89
internal/handler/auth_handler.go
Normal 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})
|
||||||
|
}
|
||||||
56
internal/handler/brand_handler.go
Normal file
56
internal/handler/brand_handler.go
Normal 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
109
internal/handler/handler.go
Normal 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"
|
||||||
|
}
|
||||||
18
internal/handler/health_handler.go
Normal file
18
internal/handler/health_handler.go
Normal 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"})
|
||||||
|
}
|
||||||
90
internal/handler/ssg_handler.go
Normal file
90
internal/handler/ssg_handler.go
Normal 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)
|
||||||
|
}
|
||||||
57
internal/middleware/auth.go
Normal file
57
internal/middleware/auth.go
Normal 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
|
||||||
|
}
|
||||||
55
internal/middleware/cors.go
Normal file
55
internal/middleware/cors.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
40
internal/middleware/ssg_token.go
Normal file
40
internal/middleware/ssg_token.go
Normal 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
21
internal/model/brand.go
Normal 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
56
internal/model/runway.go
Normal 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"`
|
||||||
|
}
|
||||||
17
internal/model/runway_image.go
Normal file
17
internal/model/runway_image.go
Normal 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
17
internal/model/user.go
Normal 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" }
|
||||||
126
internal/pkg/hashid/hashid.go
Normal file
126
internal/pkg/hashid/hashid.go
Normal 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))
|
||||||
|
}
|
||||||
42
internal/pkg/hashid/hashid_test.go
Normal file
42
internal/pkg/hashid/hashid_test.go
Normal 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
82
internal/pkg/jwt/jwt.go
Normal 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
|
||||||
|
}
|
||||||
52
internal/pkg/response/response.go
Normal file
52
internal/pkg/response/response.go
Normal 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
|
||||||
|
}
|
||||||
13
internal/pkg/textutil/textutil.go
Normal file
13
internal/pkg/textutil/textutil.go
Normal 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]) + "…"
|
||||||
|
}
|
||||||
208
internal/repository/article_repository.go
Normal file
208
internal/repository/article_repository.go
Normal 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
|
||||||
|
}
|
||||||
292
internal/repository/brand_repository.go
Normal file
292
internal/repository/brand_repository.go
Normal 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
|
||||||
|
}
|
||||||
12
internal/repository/errors.go
Normal file
12
internal/repository/errors.go
Normal 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")
|
||||||
71
internal/repository/user_repository.go
Normal file
71
internal/repository/user_repository.go
Normal 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
63
internal/router/router.go
Normal 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
72
internal/router/ssg.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
internal/service/article_service.go
Normal file
146
internal/service/article_service.go
Normal 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
|
||||||
|
}
|
||||||
117
internal/service/auth_service.go
Normal file
117
internal/service/auth_service.go
Normal 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}
|
||||||
|
}
|
||||||
91
internal/service/brand_service.go
Normal file
91
internal/service/brand_service.go
Normal 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
|
||||||
|
}
|
||||||
34
internal/service/errors.go
Normal file
34
internal/service/errors.go
Normal 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, "文章不存在")
|
||||||
33
internal/service/locale.go
Normal file
33
internal/service/locale.go
Normal 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
|
||||||
|
}
|
||||||
34
scripts/fashionapi.service
Normal file
34
scripts/fashionapi.service
Normal 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
106
scripts/i18n/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
scripts/sql/002_i18n_columns.sql
Normal file
49
scripts/sql/002_i18n_columns.sql
Normal 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 = '';
|
||||||
8
scripts/sql/003_drop_brand_alias.sql
Normal file
8
scripts/sql/003_drop_brand_alias.sql
Normal 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
0
uploads/.gitkeep
Normal file
Reference in New Issue
Block a user