diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..201e4af --- /dev/null +++ b/.env.example @@ -0,0 +1,58 @@ +# Fashion API 环境变量示例 +# +# 复制为 .env 供 docker-compose / 容器注入;也可直接以环境变量形式注入。 +# 本文件只作模板,不含真实密钥。密钥也可在本地「终端交互启动」时按提示填写(写回 config.local.yml)。 +# +# 加载优先级:环境变量 > config.yml > config.local.yml > 交互提示(仅本地 TTY) +# 注意:config.yml 中以下敏感项已留空,必须二选一提供: +# JWT_SECRET / DB_PASSWORD / INGEST_SECRET / HASHID_SECRET / S4_AK / S4_SK + +# ---- 服务 ---- +SERVER_PORT=8090 +GIN_MODE=release +SSG_PORT=8091 +SSG_TOKEN= +BACKSTAGE_PORT=8092 +HASHID_SECRET= + +# ---- 数据库 (PostgreSQL + pgvector) ---- +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_USER=fashion +DB_PASSWORD= +DB_NAME=fashion +DB_LOG_LEVEL=warn + +# ---- JWT ---- +JWT_SECRET= +JWT_EXPIRE_HOURS=168 +JWT_REFRESH_EXPIRE_HOURS=720 + +# ---- 爬虫上报 HMAC ---- +INGEST_SECRET= +INGEST_TTL=300 +INGEST_DOWNLOAD_PROXY= + +# ---- 公开接口前端 JS 签名 ---- +CLIENT_SIGN_ENABLED=false +CLIENT_SIGN_SECRET= +CLIENT_SIGN_TTL=30 + +# ---- 跨域(CORS):逗号分隔的具体域名,覆盖 config.yml 的 allow_origins ---- +CORS_ALLOW_ORIGINS=https://xisoa.com,https://www.xisoa.com + +# ---- 按 IP 速率限制(令牌桶,防刷兜底)---- +RATE_LIMIT_ENABLED=false +RATE_LIMIT_RPS=20 +RATE_LIMIT_BURST=40 + +# ---- 缤纷云 S4 (S3 兼容,图片存储) ---- +S4_ENABLED=true +S4_AK= +S4_SK= +S4_BUCKET=toomstudio +S4_ENDPOINT=https://s3.bitiful.net +S4_REGION=cn-east-1 +S4_BASE_URL= +S4_STYLE_DISPLAY=high +S4_STYLE_THUMB=thumb diff --git a/cmd/dbtool/main.go b/cmd/dbtool/main.go index df310af..ca41ecd 100644 --- a/cmd/dbtool/main.go +++ b/cmd/dbtool/main.go @@ -62,6 +62,7 @@ import ( "fmt" "log" "os" + "path/filepath" "sort" "strings" "time" @@ -111,6 +112,8 @@ func main() { runFixDup(os.Args[2:]) case "purge-rejected": runPurgeRejected(os.Args[2:]) + case "migrate": + runMigrate(os.Args[2:]) default: usage() os.Exit(2) @@ -131,6 +134,11 @@ func usage() { 把现存「已驳回(rejected)」且未下架的记录转删除:级联软删其全部图片、记录置 is_deleted=1(后台即消失),并把图片 key 入队 media_cleanup,由运行中的 worker 按引用计数清理 S4 孤儿文件(与后台点「删除(清空 S4)」等价,但一次性全局处理) + dbtool migrate [-config ] + 执行 db/migrations 下的迁移脚本(按文件名排序、逐文件执行、幂等可重跑): + 每个文件执行成功后记入 schema_migrations,重跑已记录的会跳过;库内「已存在」 + 视为已应用(也记入记录表后跳过),因此历史已手动 psql 过的迁移重跑不会报错中断。 + -in 指定单个文件时,只执行该文件。本子命令替代 psql,免装客户端即可落库。 说明: 连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。 @@ -405,6 +413,110 @@ func runPurgeRejected(args []string) { log.Printf("✓ 完成:转删除记录 %d 条、图片 %d 张,已入队 media_cleanup(运行中 worker 将按引用计数清理 S4)", totalRecs, totalKeys) } +// runMigrate 执行 db/migrations 下的迁移脚本(按文件名排序、逐文件执行、幂等可重跑)。 +// +// 每个文件执行成功后记入 schema_migrations(name),重跑已记录的会直接跳过; +// 库内「已存在」(如 ALTER ADD COLUMN 报 column already exists,说明该迁移早先被 +// 手动 psql 执行过)也视作已应用,记入记录表后跳过——这样历史已手动落库的迁移重跑 +// 不会报错中断,新迁移则正常执行。 +// +// 用现有的 execScript(pgx 简单查询协议,整段脚本在一个隐式事务里执行)跑,免装 psql。 +// 默认执行 db/migrations/*.sql;-in 指定单文件时只执行该文件。 +func runMigrate(args []string) { + fs := flag.NewFlagSet("migrate", flag.ExitOnError) + in := fs.String("in", "", "只执行单个迁移文件(默认执行 db/migrations 下全部 *.sql,按文件名排序)") + cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)") + _ = fs.Parse(args) + + db, dcfg, err := openDB(*cfgPath) + if err != nil { + log.Fatalf("✗ %v", err) + } + defer db.Close() + + if err := ensureMigrationsTable(db); err != nil { + log.Fatalf("✗ 建迁移记录表失败: %v", err) + } + + files, err := migrationFiles(*in) + if err != nil { + log.Fatalf("✗ %v", err) + } + if len(files) == 0 { + log.Fatalf("✗ 没有待执行迁移(检查 db/migrations 目录或 -in 路径)") + } + + for _, path := range files { + name := filepath.Base(path) + applied, err := isMigrationApplied(db, name) + if err != nil { + log.Fatalf("✗ 查询迁移记录失败: %v", err) + } + if applied { + log.Printf("→ 跳过(已应用): %s", name) + continue + } + script, err := os.ReadFile(path) + if err != nil { + log.Fatalf("✗ 读迁移文件 %s 失败: %v", path, err) + } + log.Printf("→ 执行迁移: %s", name) + if err := execScript(context.Background(), db, string(script)); err != nil { + if strings.Contains(err.Error(), "already exists") { + log.Printf("→ 跳过(库内已存在,视为已应用): %s", name) + if rerr := recordMigration(db, name); rerr != nil { + log.Fatalf("✗ 记录迁移失败: %v", rerr) + } + continue + } + log.Fatalf("✗ 迁移失败 %s(已整体回滚): %v", name, err) + } + if err := recordMigration(db, name); err != nil { + log.Fatalf("✗ 记录迁移失败: %v", err) + } + log.Printf("✓ 已应用: %s", name) + } + log.Printf("✓ 迁移完成(%s)", dcfg.Addr()) +} + +// migrationFiles 返回待执行的迁移文件列表(按文件名排序)。-in 指定单文件时只返回它。 +func migrationFiles(in string) ([]string, error) { + if in != "" { + return []string{in}, nil + } + matches, err := filepath.Glob("db/migrations/*.sql") + if err != nil { + return nil, err + } + sort.Strings(matches) + return matches, nil +} + +// ensureMigrationsTable 建迁移记录表(幂等)。 +func ensureMigrationsTable(db *sql.DB) error { + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS public.schema_migrations ( + name text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + )`) + return err +} + +// isMigrationApplied 该迁移是否已记录(已成功执行)。 +func isMigrationApplied(db *sql.DB, name string) (bool, error) { + var n int + err := db.QueryRow(`SELECT count(*) FROM public.schema_migrations WHERE name = $1`, name).Scan(&n) + if err != nil { + return false, err + } + return n > 0, nil +} + +// recordMigration 把迁移记入记录表(幂等,重跑不报错)。 +func recordMigration(db *sql.DB, name string) error { + _, err := db.Exec(`INSERT INTO public.schema_migrations (name) VALUES ($1) ON CONFLICT (name) DO NOTHING`, name) + return err +} + // selectUint32s 执行返回单列 uint32 的查询。 func selectUint32s(db *sql.DB, q string, args ...any) ([]uint32, error) { rows, err := db.Query(q, args...) diff --git a/cmd/server/main.go b/cmd/server/main.go index 66942d3..20067a3 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,7 +8,7 @@ import ( "context" "errors" "flag" - "log" + "log/slog" "net/http" "os" "os/signal" @@ -41,6 +41,19 @@ func s4BaseURL(cfgBase string, up *storage.S4Uploader) string { return cfgBase } +// setupLogger 根据运行模式初始化结构化日志(JSON 输出,便于集中采集)。 +func setupLogger(mode string) { + level := slog.LevelDebug + switch mode { + case "release": + level = slog.LevelInfo + case "test": + level = slog.LevelWarn + } + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}) + slog.SetDefault(slog.New(handler)) +} + func main() { configPath := flag.String("config", "", "配置文件路径,默认查找 configs/config.yml") flag.Parse() @@ -48,35 +61,46 @@ func main() { // 1. 加载配置(yml 为主,环境变量可覆盖) cfg, err := config.Load(*configPath) if err != nil { - log.Fatalf("✗ 加载配置失败: %v", err) + slog.Error("加载配置失败", "error", err) + os.Exit(1) } - if from := cfg.LoadedFrom(); from != "" { - log.Println("✓ 配置文件:", from) - } else { - log.Println("! 未找到配置文件,使用默认值与环境变量") + // 缺失的必填/推荐敏感配置:本地 TTY 交互提示补填(写回 config.local.yml), + // 容器/CI 等非交互环境缺失必填项则直接报错退出并提示用环境变量注入。 + if err := config.PromptMissing(cfg); err != nil { + slog.Error("启动前校验失败", "error", err) + os.Exit(1) } gin.SetMode(cfg.Server.Mode) + setupLogger(cfg.Server.Mode) + + if from := cfg.LoadedFrom(); from != "" { + slog.Info("配置文件", "path", from) + } else { + slog.Warn("未找到配置文件,使用默认值与环境变量") + } // 2. 连接数据库 db, err := database.New(cfg.Database) if err != nil { - log.Fatalf("✗ %v", err) + slog.Error("数据库连接失败", "error", err) + os.Exit(1) } defer func() { if err := database.Close(db); err != nil { - log.Printf("! 关闭数据库连接失败: %v", err) + slog.Error("关闭数据库连接失败", "error", err) } }() // 2.5 结构不再随启动自动迁移:全库「结构 + 索引 + 数据」统一由 cmd/dbtool 导出的 // 纯 SQL 维护(dbtool dump → psql -f)。这里只探活,不执行任何 DDL。 - log.Println("✓ PostgreSQL 已连接:", cfg.Database.Addr()) + slog.Info("PostgreSQL 已连接", "addr", cfg.Database.Addr()) // 3. 确保上传目录存在(静态文件服务的根目录) uploadDir, _ := filepath.Abs(cfg.Upload.Dir) if err := os.MkdirAll(uploadDir, 0o755); err != nil { - log.Fatalf("✗ 创建上传目录失败: %v", err) + slog.Error("创建上传目录失败", "error", err) + os.Exit(1) } - log.Printf("✓ 静态资源: %s → %s", cfg.Upload.URLPrefix, uploadDir) + slog.Info("静态资源", "prefix", cfg.Upload.URLPrefix, "dir", uploadDir) // 4.5 初始化公开 ID 混淆(HashID)。 // 对外接口的 id / brand_id 一律用 hashid 编码串,内部仍用数字主键; @@ -137,9 +161,9 @@ func main() { // free 走公开 CoreIX 样式、VIP 走预签名原图,既方便换域名又能做分级(见 internal/pkg/imgurl)。 imgUp = s4Up imgDel = s4Up - log.Printf("✓ 图片存储: 缤纷云 S4 bucket=%s base=%s(库只存 key,渲染时拼 base_url)", cfg.S4.Bucket, s4Up.PublicBaseURL()) + slog.Info("图片存储(S4)", "bucket", cfg.S4.Bucket, "base", s4Up.PublicBaseURL()) } else { - log.Printf("✓ 图片存储: 本地 %s", cfg.Upload.URLPrefix) + slog.Info("图片存储(本地)", "prefix", cfg.Upload.URLPrefix) } // 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。 // 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。 @@ -155,9 +179,9 @@ func main() { Brand: handler.NewBrandHandler(brandSvc), StreetSnap: handler.NewStreetSnapHandler(snapSvc, favSvc), Auth: handler.NewAuthHandler(authSvc), - Favorite: handler.NewFavoriteHandler(favSvc), - History: handler.NewHistoryHandler(histSvc), - Health: handler.NewHealthHandler(), + Favorite: handler.NewFavoriteHandler(favSvc), + History: handler.NewHistoryHandler(histSvc), + Health: handler.NewHealthHandler(), }) // 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。 @@ -172,9 +196,10 @@ func main() { } go func() { - log.Printf("🚀 公开服务已启动: http://localhost:%s", cfg.Server.Port) + slog.Info("公开服务已启动", "addr", "http://localhost:"+cfg.Server.Port) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("✗ 公开服务启动失败: %v", err) + slog.Error("公开服务启动失败", "error", err) + os.Exit(1) } }() @@ -191,9 +216,10 @@ func main() { } go func() { - log.Printf("🔒 SSG 内部服务已启动: http://localhost:%s (仅构建期/回环可达)", cfg.Server.SSGPort) + slog.Info("SSG 内部服务已启动", "addr", "http://localhost:"+cfg.Server.SSGPort) if err := srvSSG.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("✗ SSG 内部服务启动失败: %v", err) + slog.Error("SSG 内部服务启动失败", "error", err) + os.Exit(1) } }() @@ -216,15 +242,16 @@ func main() { // 用独立可取消 ctx,优雅关闭时随主流程一起退出。 workerCtx, workerCancel := context.WithCancel(context.Background()) go ingestSvc.Run(workerCtx, 2, 3*time.Second) - log.Printf("⚙ 入库 worker 已启动(队列 ingest_jobs)") + slog.Info("入库 worker 已启动", "queue", "ingest_jobs") srvBackstage := &http.Server{ Addr: ":" + cfg.Server.BackstagePort, Handler: backstageEngine, } go func() { - log.Printf("🛠 管理后台已启动: http://localhost:%s/admin", cfg.Server.BackstagePort) + slog.Info("管理后台已启动", "addr", "http://localhost:"+cfg.Server.BackstagePort+"/admin") if err := srvBackstage.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("✗ 管理后台启动失败: %v", err) + slog.Error("管理后台启动失败", "error", err) + os.Exit(1) } }() @@ -233,20 +260,20 @@ func main() { quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit - log.Println("→ 正在关闭服务 ...") + slog.Info("正在关闭服务") ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second) defer cancel() workerCancel() // 通知入库 worker 停止领取新任务 if err := srv.Shutdown(ctx); err != nil { - log.Printf("! 公开服务优雅关闭超时: %v", err) + slog.Error("公开服务优雅关闭超时", "error", err) } if err := srvSSG.Shutdown(ctx); err != nil { - log.Printf("! SSG 内部服务优雅关闭超时: %v", err) + slog.Error("SSG 内部服务优雅关闭超时", "error", err) } if err := srvBackstage.Shutdown(ctx); err != nil { - log.Printf("! 管理后台优雅关闭超时: %v", err) + slog.Error("管理后台优雅关闭超时", "error", err) } - log.Println("✓ 服务已停止") + slog.Info("服务已停止") } diff --git a/configs/config.yml b/configs/config.yml index b79204a..a793ea7 100644 --- a/configs/config.yml +++ b/configs/config.yml @@ -1,9 +1,13 @@ # ============================================================ -# Fashion API 配置文件(唯一配置源) +# Fashion API 配置文件(唯一配置源,但敏感项一律留空) # -# 加载优先级:环境变量 > 本文件 > 代码内置默认值 -# - 本地开发:直接改本文件即可,无需任何环境变量 -# - 容器部署:保持本文件为默认值,用环境变量覆盖敏感项(见每项 env 注释) +# 加载优先级:环境变量 > 本文件 > config.local.yml(本机私有)> 交互提示(仅本地 TTY) +# - 本地开发:终端启动时会交互式提示填写缺失的必填/推荐密钥,并自动写入 config.local.yml +# - 容器部署:保持本文件为默认值,用环境变量(见 .env.example)覆盖敏感项 +# +# ⚠️ 重要:JWT_SECRET / DB_PASSWORD / INGEST_SECRET / S4_AK / S4_SK 等敏感值 +# 严禁写入本文件(会被提交进版本库)。本文件已将它们留空,请从环境变量或 +# 本地交互提示获取。S4 的旧 AK/SK 曾误提交,已轮换并清空,请勿再填回明文。 # # 配置文件路径可通过 -config 启动参数或 CONFIG_PATH 环境变量指定, # 默认按 ./configs/config.yml → ./config.yml 顺序查找。 @@ -22,7 +26,8 @@ database: host: 127.0.0.1 # env: DB_HOST port: 5432 # env: DB_PORT user: fashion # env: DB_USER - password: fashion_dev_2026 # env: DB_PASSWORD(兼容 DB_PASS) + # ⚠️ 留空:生产经 DB_PASSWORD(兼容 DB_PASS)注入,或本地启动时交互填写 + password: "" # env: DB_PASSWORD(兼容 DB_PASS) # 本地开发库名 fashion;对应 scripts/pgvector 的 Docker PostgreSQL 容器 name: fashion # env: DB_NAME # GORM 日志级别:silent | error | warn | info @@ -34,8 +39,8 @@ database: conn_max_idle_time: 60 # 秒,空闲连接回收时间,防止池子持有被服务端回收的死连接导致 invalid connection jwt: - # 生产环境务必用环境变量覆盖为随机长字符串 - secret: dev-secret-change-me-fashion-2026 # env: JWT_SECRET + # ⚠️ 留空:生产经 JWT_SECRET 注入随机长字符串,或本地启动时交互填写 + secret: "" # env: JWT_SECRET # token 有效期(小时),168 = 7 天,与原项目一致 expire_hours: 168 @@ -47,9 +52,11 @@ upload: url_prefix: /uploads cors: - # 允许的来源,"*" 表示全部(与原项目行为一致) + # 允许的来源:生产必须收窄为具体域名列表(不要用 "*",否则任意站点可跨域调用)。 + # 也可用环境变量 CORS_ALLOW_ORIGINS=https://a.com,https://b.com 在容器里覆盖本值。 allow_origins: - - "*" + - "https://xisoa.com" + - "https://www.xisoa.com" allow_methods: - GET - POST @@ -77,13 +84,21 @@ client_sign: secret: "" # env: CLIENT_SIGN_SECRET ttl_seconds: 30 # 签名时间戳容忍窗口(秒),防重放;env: CLIENT_SIGN_TTL +# 按客户端 IP 的速率限制(令牌桶),作为防刷兜底(与 client_sign 配合)。 +# 默认关闭;生产建议开启(RATE_LIMIT_ENABLED=true),按单 IP 限制总量。 +rate_limit: + enabled: false # env: RATE_LIMIT_ENABLED + rps: 20 # env: RATE_LIMIT_RPS(每秒平均允许请求数) + burst: 40 # env: RATE_LIMIT_BURST(突发容量) + # 爬虫入库管线:暴露 :8092 /admin/internal/ingest 接收爬虫 HMAC 签名上报。 # 设计:服务端到服务端(非前端 JS),密钥绝不下发前端、只存爬虫配置与 INGEST_SECRET,安全高。 # - secret 必须与爬虫端 config.yml 的 ingest.secret 完全一致;env INGEST_SECRET 注入随机长串(生产)。 # - 建表不再由服务启动自动完成:表结构随全库由 cmd/dbtool 导出的 SQL 维护(dbtool dump → psql -f)。 # - 启用后 worker 随主进程拉起(cmd/server 内 goroutine),异步下载图、补 season_code、写正式表。 ingest: - secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致) + # ⚠️ 留空:生产经 INGEST_SECRET 注入,或本地启动时交互填写 + secret: "" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致) ttl_seconds: 300 # 签名时间戳容忍窗口(秒,±5min),防重放;env: INGEST_TTL # 本机下载代理(仅本地开发用,不提交):worker 下载图片(如 assets.vogue.com)经此代理访问源站。 # 支持 http:// 或 socks5://,例如 socks5://127.0.0.1:51831 或 http://127.0.0.1:58591。 @@ -99,16 +114,16 @@ ingest: # - ak/sk:缤纷云子账户 AccessKey/SecretKey(须有 PutObject/DeleteObject/GetObject 权限) # - style_display:全量展示图 CoreIX 样式名(VIP 与免费用户同质量)。须在控制台建一个公开样式 high, # 或填查询串(如 w=1080&q=80&fmt=webp)免建样式。代码含 "=" 即走查询串模式。 +# ⚠️ ak/sk 已留空(旧密钥曾误提交,已轮换)。生产经 S4_AK / S4_SK 注入,或本地启动时交互填写。 # 环境变量覆盖:S4_AK / S4_SK / S4_BUCKET / S4_ENDPOINT / S4_BASE_URL / S4_STYLE_DISPLAY / S4_STYLE_THUMB # (旧 QINIU_* 仅 QINIU_STYLE_NORMAL 作 style_display 的兼容回退;S4_* 优先) s4: enabled: true - ak: "ur2YaIFZvySmjTIgGT1fxhAW" - sk: "JUlfcQ4HNgO5eIzj9u7qaeQomqssvtI" + ak: "" # env: S4_AK(必填,启用 S4 时) + sk: "" # env: S4_SK(必填,启用 S4 时) bucket: "toomstudio" # 你的缤纷云 S4 桶名 endpoint: "https://s3.bitiful.net" region: "cn-east-1" base_url: "" # 留空自动推导 https://toomstudio.s3.bitiful.net style_display: "high" # 高清公开样式名(须在缤纷云控制台建 public 样式 high);或填查询串 w=1080&q=80&fmt=webp style_thumb: "thumb" # 列表缩略图公开样式名(控制台建 public 样式 thumb);仅用于列表/卡片等小尺寸场景,详情页与大图仍用 style_display。留空则回落 style_display - diff --git a/db/migrations/2026-09-29-01-image-original-size.sql b/db/migrations/2026-09-29-01-image-original-size.sql new file mode 100644 index 0000000..5ac26ec --- /dev/null +++ b/db/migrations/2026-09-29-01-image-original-size.sql @@ -0,0 +1,8 @@ +-- 2026-09-29 原图大小:worker 下载图片时记录源文件字节数(image_size), +-- 供后台「图集原图总大小」展示。历史已入库图片为 0(未重爬、无源数据),重爬后自动补齐。 +-- +-- 应用方式(连库执行): +-- psql "$DATABASE_URL" -f db/migrations/2026-09-29-01-image-original-size.sql + +ALTER TABLE brand_runway_images ADD COLUMN image_size bigint NOT NULL DEFAULT 0; +ALTER TABLE street_snap_images ADD COLUMN image_size bigint NOT NULL DEFAULT 0; diff --git a/go.mod b/go.mod index 3b26bf0..4e713ef 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( golang.org/x/crypto v0.54.0 gorm.io/driver/postgres v1.6.3 gorm.io/gorm v1.31.2 + github.com/mattn/go-isatty v0.0.20 ) require ( @@ -49,7 +50,6 @@ require ( 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 diff --git a/internal/config/config.go b/internal/config/config.go index be24172..ecec7b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,8 @@ type Config struct { Upload UploadConfig `yaml:"upload"` CORS CORSConfig `yaml:"cors"` ClientSign ClientSignConfig `yaml:"client_sign"` + // RateLimit 按客户端 IP 的速率限制(兜底防刷,与 client_sign 配合)。 + RateLimit RateLimitConfig `yaml:"rate_limit"` // Ingest 爬虫上报接口的 HMAC 验签配置(服务端到服务端,密钥不下发前端)。 Ingest IngestConfig `yaml:"ingest"` // S4 缤纷云对象存储配置(S3 兼容,爬虫入库图片上传目标)。 @@ -171,6 +173,13 @@ type ClientSignConfig struct { TTLSeconds int `yaml:"ttl_seconds"` } +// RateLimitConfig 按客户端 IP 的速率限制配置(令牌桶)。 +type RateLimitConfig struct { + Enabled bool `yaml:"enabled"` + RPS int `yaml:"rps"` // 每秒补充令牌数(平均允许 QPS) + Burst int `yaml:"burst"` // 突发容量(瞬间允许的最大请求数) +} + // defaultConfig 返回内置默认值,保证 yml 缺字段时服务仍可启动。 func defaultConfig() *Config { return &Config{ @@ -215,6 +224,11 @@ func defaultConfig() *Config { Secret: "", TTLSeconds: 30, }, + RateLimit: RateLimitConfig{ + Enabled: false, + RPS: 20, + Burst: 40, + }, Ingest: IngestConfig{ Secret: "", TTLSeconds: 300, @@ -332,6 +346,20 @@ func (c *Config) applyEnv() { envStr("CLIENT_SIGN_SECRET", &c.ClientSign.Secret) envInt("CLIENT_SIGN_TTL", &c.ClientSign.TTLSeconds) + // CORS 允许的来源(逗号分隔),覆盖 yml 的 allow_origins,便于容器注入具体域名收紧跨域。 + if v := strings.TrimSpace(os.Getenv("CORS_ALLOW_ORIGINS")); v != "" { + parts := strings.Split(v, ",") + c.CORS.AllowOrigins = make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + c.CORS.AllowOrigins = append(c.CORS.AllowOrigins, p) + } + } + } + envBool("RATE_LIMIT_ENABLED", &c.RateLimit.Enabled) + envInt("RATE_LIMIT_RPS", &c.RateLimit.RPS) + envInt("RATE_LIMIT_BURST", &c.RateLimit.Burst) + envStr("UPLOAD_DIR", &c.Upload.Dir) envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix) @@ -383,6 +411,12 @@ func (c *Config) normalize() { if c.JWT.RefreshExpireHours <= 0 { c.JWT.RefreshExpireHours = 720 } + if c.RateLimit.RPS <= 0 { + c.RateLimit.RPS = 20 + } + if c.RateLimit.Burst <= 0 { + c.RateLimit.Burst = 40 + } if c.Upload.Dir == "" { c.Upload.Dir = "./uploads" } diff --git a/internal/config/prompt.go b/internal/config/prompt.go new file mode 100644 index 0000000..2ece1fe --- /dev/null +++ b/internal/config/prompt.go @@ -0,0 +1,212 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/goccy/go-yaml" + "github.com/mattn/go-isatty" +) + +// requiredSpec 描述一个「必填 / 推荐」敏感配置项,以及取值、赋值与回写路径。 +// +// 解析顺序:环境变量 > config.yml > config.local.yml > 交互提示(仅 TTY)> 非 TTY 缺失则 fatal。 +// 这样关键配置无需硬编码进仓库:仓库里的 config.yml 把这些项留空, +// 本地开发在终端里交互填写(自动写回 config.local.yml,已被 .gitignore 忽略), +// 容器 / CI 等无终端环境则通过环境变量注入,缺失必填项直接报错退出。 +type requiredSpec struct { + Name string // 展示名 + EnvKey string // 对应环境变量名(非 TTY 报错提示 + 文档) + Secret bool // 是否为密钥(仅影响提示文案) + Optional bool // true=缺失只告警不阻断;false=缺失且非 TTY 时 fatal + Get func(c *Config) string + Set func(c *Config, v string) + YAMLPath []string // 写回 config.local.yml 的嵌套路径 + Cond func(c *Config) bool // 是否参与校验(如 S4 启用才要求 ak/sk) +} + +func requiredSpecs() []requiredSpec { + return []requiredSpec{ + { + Name: "JWT 签名密钥", + EnvKey: "JWT_SECRET", + Secret: true, + Optional: false, + Get: func(c *Config) string { return c.JWT.Secret }, + Set: func(c *Config, v string) { c.JWT.Secret = v }, + YAMLPath: []string{"jwt", "secret"}, + }, + { + Name: "数据库密码", + EnvKey: "DB_PASSWORD", + Secret: true, + Optional: false, + Get: func(c *Config) string { return c.Database.Password }, + Set: func(c *Config, v string) { c.Database.Password = v }, + YAMLPath: []string{"database", "password"}, + }, + { + Name: "爬虫上报 HMAC 密钥", + EnvKey: "INGEST_SECRET", + Secret: true, + Optional: true, + Get: func(c *Config) string { return c.Ingest.Secret }, + Set: func(c *Config, v string) { c.Ingest.Secret = v }, + YAMLPath: []string{"ingest", "secret"}, + }, + { + Name: "公开 ID 混淆盐", + EnvKey: "HASHID_SECRET", + Secret: true, + Optional: true, + Get: func(c *Config) string { return c.Server.HashIDSecret }, + Set: func(c *Config, v string) { c.Server.HashIDSecret = v }, + YAMLPath: []string{"server", "hashid_secret"}, + }, + { + Name: "S4 对象存储 AccessKey", + EnvKey: "S4_AK", + Secret: true, + Optional: false, + Cond: func(c *Config) bool { return c.S4.Enabled }, + Get: func(c *Config) string { return c.S4.AK }, + Set: func(c *Config, v string) { c.S4.AK = v }, + YAMLPath: []string{"s4", "ak"}, + }, + { + Name: "S4 对象存储 SecretKey", + EnvKey: "S4_SK", + Secret: true, + Optional: false, + Cond: func(c *Config) bool { return c.S4.Enabled }, + Get: func(c *Config) string { return c.S4.SK }, + Set: func(c *Config, v string) { c.S4.SK = v }, + YAMLPath: []string{"s4", "sk"}, + }, + } +} + +// PromptMissing 在配置加载完成后,对缺失的必填/推荐敏感配置做交互式补填。 +// 交互补填的值会写回 config.local.yml(已被 .gitignore 忽略),下次启动免填。 +// 非交互环境(容器 / CI,无终端输入)下,缺失的必填项直接返回 error,由调用方 fatal 退出, +// 并打印应注入的环境变量名。 +func PromptMissing(cfg *Config) error { + var missing []requiredSpec + for _, s := range requiredSpecs() { + if s.Cond != nil && !s.Cond(cfg) { + continue + } + if strings.TrimSpace(s.Get(cfg)) == "" { + missing = append(missing, s) + } + } + if len(missing) == 0 { + return nil + } + + if !isTerminal(os.Stdin) { + var fatalLines, warnLines []string + for _, s := range missing { + if s.Optional { + warnLines = append(warnLines, fmt.Sprintf(" ! %s 未设置(建议通过环境变量 %s 注入)", s.Name, s.EnvKey)) + } else { + fatalLines = append(fatalLines, fmt.Sprintf(" - %s(请设置环境变量 %s)", s.Name, s.EnvKey)) + } + } + for _, l := range warnLines { + fmt.Println(l) + } + if len(fatalLines) > 0 { + return fmt.Errorf("缺少必填配置,无法启动:\n%s", strings.Join(fatalLines, "\n")) + } + return nil + } + + fmt.Println("以下必填/推荐配置缺失,请交互式填写(将写入 config.local.yml,已被 .gitignore 忽略):") + reader := bufio.NewReader(os.Stdin) + changed := map[string]string{} + for _, s := range missing { + val, err := promptLine(reader, s) + if err != nil { + return err + } + s.Set(cfg, val) + changed[strings.Join(s.YAMLPath, ".")] = val + } + return writeLocalOverlay(changed) +} + +func promptLine(r *bufio.Reader, s requiredSpec) (string, error) { + tag := "" + if s.Secret { + tag = "(密钥,输入不会回显保存,请自行保密)" + } + fmt.Printf(" %s [%s]%s: ", s.Name, s.EnvKey, tag) + val, err := r.ReadString('\n') + if err != nil && val == "" { + return "", fmt.Errorf("读取 %s 失败: %w", s.Name, err) + } + val = strings.TrimRight(val, "\r\n") + if strings.TrimSpace(val) == "" { + return "", fmt.Errorf("%s 不能为空", s.Name) + } + return val, nil +} + +// writeLocalOverlay 把交互补填的值合并写回 config.local.yml(已 gitignore)。 +// 若文件已存在,保留其中未被覆盖的其它键(如 download_proxy)。 +func writeLocalOverlay(changed map[string]string) error { + candidates := []string{ + "configs/config.local.yml", + "config.local.yml", + filepath.Join("..", "..", "configs", "config.local.yml"), + } + target := candidates[0] + var m map[string]interface{} + for _, p := range candidates { + data, err := os.ReadFile(p) + if err == nil { + _ = yaml.Unmarshal(data, &m) + target = p + break + } + } + if m == nil { + m = map[string]interface{}{} + } + for dotted, val := range changed { + setNested(m, strings.Split(dotted, "."), val) + } + out, err := yaml.Marshal(m) + if err != nil { + return fmt.Errorf("序列化 config.local.yml 失败: %w", err) + } + if err := os.WriteFile(target, out, 0o600); err != nil { + return fmt.Errorf("写入 %s 失败: %w", target, err) + } + fmt.Printf("✓ 已写入本地私有配置: %s(已被 .gitignore 忽略,不会进版本库)\n", target) + return nil +} + +func setNested(m map[string]interface{}, path []string, val string) { + for i := 0; i < len(path)-1; i++ { + k := path[i] + child, ok := m[k].(map[string]interface{}) + if !ok { + child = map[string]interface{}{} + m[k] = child + } + m = child + } + m[path[len(path)-1]] = val +} + +// isTerminal 判断文件描述符是否为真实终端,用于区分交互式启动与容器/CI 无输入环境。 +// 使用 mattn/go-isatty 而非 Stat().Mode()&ModeCharDevice,因为后者会把 /dev/null、管道 +// 等字符设备误判为终端,导致容器里错误地进入交互提示分支。 +func isTerminal(f *os.File) bool { + return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) +} diff --git a/internal/handler/backstage_handler.go b/internal/handler/backstage_handler.go index 0a51540..d789cb2 100644 --- a/internal/handler/backstage_handler.go +++ b/internal/handler/backstage_handler.go @@ -249,7 +249,8 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) { renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw}) return } - renderPage(c, http.StatusOK, "runway-detail", gin.H{"Article": art}) + total, _ := h.article.SumImageSize(c.Request.Context(), numeric) + renderPage(c, http.StatusOK, "runway-detail", gin.H{"Article": art, "ImageTotalSize": total}) } // StreetSnaps 街拍管理列表:按年份/城市/排序筛选,复用 StreetSnapService.AdminList(读基表,含待审)。 @@ -305,7 +306,8 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) { renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw}) return } - renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap}) + total, _ := h.street.SumImageSize(c.Request.Context(), numeric) + renderPage(c, http.StatusOK, "street-snap-detail", gin.H{"Snap": snap, "ImageTotalSize": total}) } // ── 文章管理(走秀 + 街拍混合列表,替代原审核页与分类型列表)───────────── @@ -327,6 +329,8 @@ func (h *BackstageHandler) Articles(c *gin.Context) { } // 批量统计每篇图集的重复率(基于 is_duplicate 列,阈值 phash.DefaultThreshold=4)。 dupStats := h.dupStatsByCards(c.Request.Context(), cards) + // 批量统计每篇图集的原图总大小(基于 image_size 列,worker 下载时写入源文件字节数)。 + imageSizes := h.imageSizeByCards(c.Request.Context(), cards) items := make([]gin.H, 0, len(cards)) for _, card := range cards { var t byte @@ -347,6 +351,7 @@ func (h *BackstageHandler) Articles(c *gin.Context) { "Title": card.Title, "Subtitle": card.Subtitle, "ImageCount": card.ImageCount, + "ImageTotalSize": imageSizes[card.ID], "Status": card.Status, "DupCount": ds.Dup, "DupPct": dupPct, @@ -407,6 +412,36 @@ func (h *BackstageHandler) dupStatsByCards(ctx context.Context, cards []reposito return out } +// imageSizeByCards 按卡片类型批量取每篇图集的原图字节数之和(image_size 列)。 +// 按 kind 分别查对应图片表,避免跨表混算;查询失败时该 kind 整体回落为「无大小」(不阻断列表)。 +func (h *BackstageHandler) imageSizeByCards(ctx context.Context, cards []repository.AdminRecordCard) map[uint32]int64 { + out := make(map[uint32]int64) + runwayIDs := make([]uint32, 0, len(cards)) + streetIDs := make([]uint32, 0, len(cards)) + for _, c := range cards { + if c.Kind == "runway" { + runwayIDs = append(runwayIDs, c.ID) + } else { + streetIDs = append(streetIDs, c.ID) + } + } + if len(runwayIDs) > 0 { + if m, err := h.adminRepo.ImageSizeByRecords(ctx, "runway", runwayIDs); err == nil { + for id, s := range m { + out[id] = s + } + } + } + if len(streetIDs) > 0 { + if m, err := h.adminRepo.ImageSizeByRecords(ctx, "street", streetIDs); err == nil { + for id, s := range m { + out[id] = s + } + } + } + return out +} + // ArticleApprove 通过:把记录状态置为已发布(单表模型:仅改状态,不重建图片)。 // 路由标识用 hashid,与编辑页一致;已发布 / 已驳回记录调用仍是幂等的。 func (h *BackstageHandler) ArticleApprove(c *gin.Context) { @@ -684,12 +719,17 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) { dupPct = float64(dupCount) / float64(n) * 100 } dupSources := h.buildDupSources(c.Request.Context(), "runway", cells) + var totalSize int64 + for _, im := range imgs { + totalSize += im.ImageSize + } renderPage(c, http.StatusOK, "runway-edit", gin.H{ "UID": raw, "Kind": "runway", "RecordID": rw.ID, "Status": rw.Status, "Runway": rw, + "ImageTotalSize": totalSize, "Images": imgs, "Cells": cells, "BrandName": brandName, @@ -869,12 +909,17 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) { dupPct = float64(dupCount) / float64(n) * 100 } dupSources := h.buildDupSources(c.Request.Context(), "street", cells) + var totalSize int64 + for _, im := range imgs { + totalSize += im.ImageSize + } renderPage(c, http.StatusOK, "snap-edit", gin.H{ "UID": raw, "Kind": "street", "RecordID": snap.ID, "Status": snap.Status, "Snap": snap, + "ImageTotalSize": totalSize, "Images": imgs, "Cells": cells, "IsDown": snap.IsDeleted == 1, diff --git a/internal/handler/templates.go b/internal/handler/templates.go index 1e9c337..821b1c0 100644 --- a/internal/handler/templates.go +++ b/internal/handler/templates.go @@ -19,6 +19,7 @@ import ( "bytes" "context" "embed" + "fmt" "html/template" "io/fs" "net/http" @@ -41,6 +42,25 @@ func SetImgComposer(c *imgurl.Composer) { } } +// formatBytes 把字节数格式化为人类可读串(B/KB/MB/GB/TB);0 或负数返回「—」。 +func formatBytes(n int64) string { + if n <= 0 { + return "—" + } + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + val := float64(n) + for _, u := range []string{"KB", "MB", "GB", "TB"} { + val /= unit + if val < unit { + return fmt.Sprintf("%.1f %s", val, u) + } + } + return fmt.Sprintf("%.1f PB", val) +} + //go:embed templates/layout.html templates/partials.html templates/pages/*.html assets/* var backstageFS embed.FS @@ -60,6 +80,7 @@ func init() { // imgThumb 同但走列表缩略图样式(小尺寸场景省流量)。两者兜底逻辑见 imgurl.Composer。 "imgURL": func(stored string) string { return imgComposer.Compose(stored) }, "imgThumb": func(stored string) string { return imgComposer.ComposeThumb(stored) }, + "formatBytes": formatBytes, } base, err := template.New("layout.html").Funcs(funcs). diff --git a/internal/handler/templates/pages/articles.html b/internal/handler/templates/pages/articles.html index 559f910..69184c5 100644 --- a/internal/handler/templates/pages/articles.html +++ b/internal/handler/templates/pages/articles.html @@ -21,7 +21,7 @@ - + {{range .Items}} @@ -30,7 +30,7 @@ - +
类型ID标题副标题图数重复率状态操作类型ID标题副标题图数原图总大小重复率状态操作
{{.ID}} {{.Title}} {{.Subtitle}}{{.ImageCount}}{{.ImageCount}}{{formatBytes .ImageTotalSize}} {{if .DupCount}}{{printf "%.0f" .DupPct}}%({{.DupCount}}){{else}}—{{end}} {{.Status}} diff --git a/internal/handler/templates/pages/runway-detail.html b/internal/handler/templates/pages/runway-detail.html index d03b775..940d084 100644 --- a/internal/handler/templates/pages/runway-detail.html +++ b/internal/handler/templates/pages/runway-detail.html @@ -6,6 +6,7 @@

编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}

{{.Article.Title}}

+

原图总大小:{{formatBytes .ImageTotalSize}}

{{range .Article.Images}}
{{.Name}}
{{range .Detail}}
{{.Name}} · 副图
{{end}}{{end}}
diff --git a/internal/handler/templates/pages/runway-edit.html b/internal/handler/templates/pages/runway-edit.html index dd0cee7..8283b6c 100644 --- a/internal/handler/templates/pages/runway-edit.html +++ b/internal/handler/templates/pages/runway-edit.html @@ -5,6 +5,7 @@ {{template "bar" .}}

品牌 {{.BrandName}}{{if .IsDown}} · 已下架{{end}}

+

原图总大小:{{formatBytes .ImageTotalSize}}

diff --git a/internal/handler/templates/pages/snap-edit.html b/internal/handler/templates/pages/snap-edit.html index 457ea95..7e3fa46 100644 --- a/internal/handler/templates/pages/snap-edit.html +++ b/internal/handler/templates/pages/snap-edit.html @@ -5,6 +5,7 @@ {{template "bar" .}}

{{if .IsDown}}已下架{{end}}

+

原图总大小:{{formatBytes .ImageTotalSize}}

diff --git a/internal/handler/templates/pages/street-snap-detail.html b/internal/handler/templates/pages/street-snap-detail.html index 851496d..8872a0c 100644 --- a/internal/handler/templates/pages/street-snap-detail.html +++ b/internal/handler/templates/pages/street-snap-detail.html @@ -6,6 +6,7 @@

编码 {{.Snap.UID}}

{{.Snap.Title}}

+

原图总大小:{{formatBytes .ImageTotalSize}}

{{range .Snap.Images}}
{{.Name}}
{{range .Detail}}
{{.Name}} · 副图
{{end}}{{end}}
diff --git a/internal/handler/templates_format_test.go b/internal/handler/templates_format_test.go new file mode 100644 index 0000000..6f8cc9e --- /dev/null +++ b/internal/handler/templates_format_test.go @@ -0,0 +1,25 @@ +package handler + +import "testing" + +// TestFormatBytes 校验后台「图集原图总大小」的字节格式化(0/负数为「—」)。 +func TestFormatBytes(t *testing.T) { + cases := []struct { + n int64 + want string + }{ + {0, "—"}, + {-1, "—"}, + {500, "500 B"}, + {1024, "1.0 KB"}, + {1536, "1.5 KB"}, + {1024 * 1024, "1.0 MB"}, + {int64(1.5 * 1024 * 1024), "1.5 MB"}, + {int64(1.5 * 1024 * 1024 * 1024), "1.5 GB"}, + } + for _, c := range cases { + if got := formatBytes(c.n); got != c.want { + t.Errorf("formatBytes(%d) = %q, want %q", c.n, got, c.want) + } + } +} diff --git a/internal/middleware/ginlogger.go b/internal/middleware/ginlogger.go new file mode 100644 index 0000000..176bab5 --- /dev/null +++ b/internal/middleware/ginlogger.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + "log/slog" +) + +// GinLogger 用结构化日志(slog,由 main 设为 JSON handler)记录每个请求, +// 替代 gin 默认的 text logger,便于生产环境集中采集与检索。 +func GinLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + raw := c.Request.URL.RawQuery + + c.Next() + + // 跳过健康检查等高频噪声路径 + if path == "/api/health" || path == "/api/v1/health" { + return + } + + latency := time.Since(start) + status := c.Writer.Status() + clientIP := c.ClientIP() + method := c.Request.Method + if raw != "" { + path = path + "?" + raw + } + + attrs := []slog.Attr{ + slog.String("method", method), + slog.String("path", path), + slog.Int("status", status), + slog.String("ip", clientIP), + slog.Duration("latency", latency), + } + if len(c.Errors) > 0 { + attrs = append(attrs, slog.String("error", c.Errors.ByType(gin.ErrorTypePrivate).String())) + } + + switch { + case status >= 500: + slog.LogAttrs(c.Request.Context(), slog.LevelError, "request", attrs...) + case status >= 400: + slog.LogAttrs(c.Request.Context(), slog.LevelWarn, "request", attrs...) + default: + slog.LogAttrs(c.Request.Context(), slog.LevelInfo, "request", attrs...) + } + } +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..de6f3f8 --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,97 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "fashionapi/internal/config" + + "github.com/gin-gonic/gin" +) + +// rateLimiter 基于令牌桶的按 IP 限流(进程内,单实例足够;多实例需换集中式如 redis)。 +// +// 设计取舍:公开接口「首页公开、翻页需登录」已天然抬高批量枚举门槛;本中间件作为 +// 兜底,按客户端 IP 限制总量,兜住 casual 爬虫与突发流量,避免单 IP 打垮服务。 +type rateLimiter struct { + mu sync.Mutex + visitors map[string]*visitor + rate float64 // 每秒补充令牌数 + burst float64 // 桶容量 + lastSeen map[string]time.Time +} + +type visitor struct { + tokens float64 + last time.Time +} + +func newRateLimiter(rps, burst int) *rateLimiter { + rl := &rateLimiter{ + visitors: make(map[string]*visitor), + lastSeen: make(map[string]time.Time), + rate: float64(rps), + burst: float64(burst), + } + go rl.cleanup(10 * time.Minute) + return rl +} + +// cleanup 周期清理长时间无活动的客户端,避免 map 无限增长。 +func (rl *rateLimiter) cleanup(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + rl.mu.Lock() + for ip, t := range rl.lastSeen { + if time.Since(t) > 3*interval { + delete(rl.visitors, ip) + delete(rl.lastSeen, ip) + } + } + rl.mu.Unlock() + } +} + +func (rl *rateLimiter) allow(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + now := time.Now() + v, ok := rl.visitors[ip] + if !ok { + // 首次访问直接发放 burst-1 个令牌(允许一个突发请求),后续按速率补充 + rl.visitors[ip] = &visitor{tokens: rl.burst - 1, last: now} + rl.lastSeen[ip] = now + return true + } + elapsed := now.Sub(v.last).Seconds() + v.tokens += elapsed * rl.rate + if v.tokens > rl.burst { + v.tokens = rl.burst + } + v.last = now + rl.lastSeen[ip] = now + if v.tokens >= 1 { + v.tokens-- + return true + } + return false +} + +// RateLimit 按 IP 的速率限制中间件。Enabled=false 时为 noop(便于灰度与本地开发)。 +func RateLimit(cfg config.RateLimitConfig) gin.HandlerFunc { + if !cfg.Enabled || cfg.RPS <= 0 { + return func(c *gin.Context) { c.Next() } + } + rl := newRateLimiter(cfg.RPS, cfg.Burst) + return func(c *gin.Context) { + if !rl.allow(c.ClientIP()) { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "too many requests", + }) + return + } + c.Next() + } +} diff --git a/internal/model/runway_image.go b/internal/model/runway_image.go index e0079b9..1f04972 100644 --- a/internal/model/runway_image.go +++ b/internal/model/runway_image.go @@ -20,6 +20,10 @@ type BrandRunwayImage struct { // =1 为细节图,parent_image_id 指向同 runway 内主图的行 id。Look 序号对产品无意义,已移除。 ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"` + // 原图大小(字节):worker 下载图片时记录源文件字节数,供后台「图集原图总大小」展示。 + // 历史已入库图片为 0(未重爬、无源数据),后台按「—」处理;重爬后自动补齐。 + ImageSize int64 `gorm:"column:image_size;not null;default:0" json:"image_size"` + // 去重字段(近重复 + 语义 embedding 留痕): // phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索; // is_duplicate / dup_of 仅在命中近重复时标记,供人工审核留痕,不阻断入库。 diff --git a/internal/model/street_snap.go b/internal/model/street_snap.go index bb959d8..6ee3c0e 100644 --- a/internal/model/street_snap.go +++ b/internal/model/street_snap.go @@ -50,6 +50,9 @@ type StreetSnapImage struct { // is_detail=0 为主图(parent_image_id=0);=1 为副图,parent_image_id 指向同专辑内主图的行 id。 IsDetail uint8 `gorm:"column:is_detail;not null;default:0" json:"is_detail"` ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"` + + // 原图大小(字节):同 BrandRunwayImage.ImageSize(见该字段注释)。 + ImageSize int64 `gorm:"column:image_size;not null;default:0" json:"image_size"` } // TableName 指定图片明细表名。 diff --git a/internal/repository/admin_repository.go b/internal/repository/admin_repository.go index ca4c5fb..79e29fd 100644 --- a/internal/repository/admin_repository.go +++ b/internal/repository/admin_repository.go @@ -39,6 +39,9 @@ type AdminRepository interface { // DupRateByRecords 批量统计若干记录的图片去重情况(仅存活行)。 // 返回 owner id → {Total 存活图片总数, Dup is_duplicate=1 张数};不存在的 id 不出现在 map 中。 DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error) + // ImageSizeByRecords 批量返回若干记录的原图字节数之和(按 kind 分别查对应图片表); + // 返回 owner id → 字节数。供文章管理混合列表展示「图集原图总大小」。 + ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]int64, error) } type adminRepository struct { @@ -148,6 +151,41 @@ func (r *adminRepository) DupRateByRecords(ctx context.Context, kind string, ids return out, nil } +// ImageSizeByRecords 批量返回若干记录的原图字节数之和(按 kind 分别查对应图片表)。 +// 返回 owner id → 字节数;不存在的 id 不出现在 map 中。供文章管理混合列表展示「图集原图总大小」。 +func (r *adminRepository) ImageSizeByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]int64, error) { + out := make(map[uint32]int64) + if len(ids) == 0 { + return out, nil + } + var table, fk string + switch kind { + case "runway": + table, fk = "brand_runway_images", "runway_id" + case "street": + table, fk = "street_snap_images", "snap_id" + default: + return out, nil + } + type agg struct { + Owner uint32 `gorm:"column:owner"` + Total int64 `gorm:"column:total"` + } + var rows []agg + if err := r.db.WithContext(ctx). + Table(table). + Select(fk + " AS owner, COALESCE(SUM(image_size), 0) AS total"). + Where(fk+" IN ? AND is_deleted = 0", ids). + Group(fk). + Scan(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + out[row.Owner] = row.Total + } + return out, nil +} + func (r *adminRepository) CountMixed(ctx context.Context, status, kind string) (int64, error) { rw, rargs := legWhere("r", status) sw, sargs := legWhere("s", status) diff --git a/internal/repository/article_repository.go b/internal/repository/article_repository.go index 3c4ed8a..81c5df9 100644 --- a/internal/repository/article_repository.go +++ b/internal/repository/article_repository.go @@ -52,6 +52,8 @@ type ArticleRepository interface { SetRunwayCover(ctx context.Context, runwayID uint32, image string) error // SetRunwayDeleted 上下架:deleted=1 下架(对外不可见),0 上架。 SetRunwayDeleted(ctx context.Context, id uint32, deleted uint8) error + // SumImageSize 返回某条走秀全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。 + SumImageSize(ctx context.Context, runwayID uint32) (int64, error) } type articleRepository struct { @@ -565,3 +567,14 @@ func (r *articleRepository) SetRunwayDeleted(ctx context.Context, id uint32, del Where("id = ?", id). Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error } + +// SumImageSize 返回某条走秀全部存活图片的原图字节数之和(image_size 由 worker 下载时写入源文件字节数)。 +func (r *articleRepository) SumImageSize(ctx context.Context, runwayID uint32) (int64, error) { + var total int64 + err := r.db.WithContext(ctx). + Model(&model.BrandRunwayImage{}). + Where("runway_id = ? AND is_deleted = 0", runwayID). + Select("COALESCE(SUM(image_size), 0)"). + Scan(&total).Error + return total, err +} diff --git a/internal/repository/street_snap_repository.go b/internal/repository/street_snap_repository.go index 2d7e3e5..253e6f0 100644 --- a/internal/repository/street_snap_repository.go +++ b/internal/repository/street_snap_repository.go @@ -54,6 +54,8 @@ type StreetSnapRepository interface { SetSnapCover(ctx context.Context, snapID uint32, image string) error // SetSnapDeleted 上下架:deleted=1 下架,0 上架。 SetSnapDeleted(ctx context.Context, id uint32, deleted uint8) error + // SumImageSize 返回某条街拍全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。 + SumImageSize(ctx context.Context, snapID uint32) (int64, error) } type streetSnapRepository struct { @@ -498,3 +500,14 @@ func (r *streetSnapRepository) SetSnapDeleted(ctx context.Context, id uint32, de Where("id = ?", id). Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error } + +// SumImageSize 返回某条街拍全部存活图片的原图字节数之和(image_size 由 worker 下载时写入源文件字节数)。 +func (r *streetSnapRepository) SumImageSize(ctx context.Context, snapID uint32) (int64, error) { + var total int64 + err := r.db.WithContext(ctx). + Model(&model.StreetSnapImage{}). + Where("snap_id = ? AND is_deleted = 0", snapID). + Select("COALESCE(SUM(image_size), 0)"). + Scan(&total).Error + return total, err +} diff --git a/internal/router/router.go b/internal/router/router.go index a6cd763..8663194 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -40,8 +40,11 @@ type Options struct { // 注意:SSG 内部接口(/api/internal/ssg/*)不在这里注册,而是由 NewSSG 挂在独立的内部端口上, // 二者物理隔离,确保构建期全量数据不会从对外公开端口泄露。 func New(opt Options) *gin.Engine { - r := gin.Default() + r := gin.New() + r.Use(gin.Recovery()) + r.Use(middleware.GinLogger()) r.Use(middleware.CORS(opt.Config.CORS)) + r.Use(middleware.RateLimit(opt.Config.RateLimit)) // 上传图片的静态服务。 // 接口返回的 cover / image 是相对路径(如 /uploads/2026/08/xx.jpg), diff --git a/internal/service/article_service.go b/internal/service/article_service.go index c858c82..9a0bde6 100644 --- a/internal/service/article_service.go +++ b/internal/service/article_service.go @@ -40,6 +40,8 @@ type ArticleService interface { SetCover(ctx context.Context, runwayID uint32, image string) error // SetDeleted 上下架:deleted=1 下架(对外不可见),0 上架。 SetDeleted(ctx context.Context, id uint32, deleted uint8) error + // SumImageSize 返回某条走秀全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。 + SumImageSize(ctx context.Context, runwayID uint32) (int64, error) // PurgeGallery 彻底下架图集:级联软删图片 + 下架 + 异步入队清理S4孤儿图。 PurgeGallery(ctx context.Context, id uint32) error } @@ -305,6 +307,11 @@ func (s *articleService) SetCover(ctx context.Context, runwayID uint32, image st return s.articles.SetRunwayCover(ctx, runwayID, image) } +// SumImageSize 返回某条走秀全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。 +func (s *articleService) SumImageSize(ctx context.Context, runwayID uint32) (int64, error) { + return s.articles.SumImageSize(ctx, runwayID) +} + func (s *articleService) SetDeleted(ctx context.Context, id uint32, deleted uint8) error { if deleted == 1 { // 下架图集:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。 diff --git a/internal/service/ingest_image_size_test.go b/internal/service/ingest_image_size_test.go new file mode 100644 index 0000000..9166ccd --- /dev/null +++ b/internal/service/ingest_image_size_test.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// fakeSizeUploader 仅用于单测:返回固定 URL,不真正写存储。 +type fakeSizeUploader struct{} + +func (fakeSizeUploader) Upload(_ context.Context, key string, _ []byte, _ string) (string, error) { + return "http://example/" + key, nil +} +func (fakeSizeUploader) Enabled() bool { return true } + +// TestDownloadAndUploadRecordsOriginalSize 校验 worker 在下载阶段记录源文件字节数 +// (即 image_size 的来源:io.ReadAll 读到的原图响应体长度)。 +func TestDownloadAndUploadRecordsOriginalSize(t *testing.T) { + body := []byte("fake-original-image-bytes-payload-of-known-length-1234567890") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(body) + })) + defer srv.Close() + + s := &IngestService{ + uploader: fakeSizeUploader{}, + httpClient: &http.Client{}, + } + + res := s.downloadAndUpload(context.Background(), srv.URL, "runway") + if res.err != nil { + t.Fatalf("downloadAndUpload 失败: %v", res.err) + } + if res.size != int64(len(body)) { + t.Fatalf("res.size = %d, want %d(源文件字节数)", res.size, len(body)) + } + if res.url == "" { + t.Fatalf("res.url 不应为空(上传应成功)") + } +} diff --git a/internal/service/ingest_service.go b/internal/service/ingest_service.go index 85adc1c..ab87bde 100644 --- a/internal/service/ingest_service.go +++ b/internal/service/ingest_service.go @@ -341,6 +341,7 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob, Phash: sqlNull(fi.phash), IsDuplicate: fi.isDup, DupOf: strconv.FormatUint(uint64(fi.dupID), 10), + ImageSize: fi.size, }) } } @@ -471,6 +472,7 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL Phash: sqlNull(r.phashBits), IsDuplicate: isDup, DupOf: strconv.FormatUint(uint64(dupID), 10), + ImageSize: r.size, }) } timing.images = len(rows) @@ -547,6 +549,7 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob, Phash: sqlNull(fi.phash), IsDuplicate: fi.isDup, DupOf: strconv.FormatUint(uint64(fi.dupID), 10), + ImageSize: fi.size, }) } var saveErr error @@ -609,6 +612,7 @@ func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix, phash: r.phashBits, dupID: dupID, isDup: isDup, + size: r.size, }) } timing.images = len(out) @@ -642,6 +646,7 @@ type fetchedImage struct { phash string // dHash 的 pgvector 二进制向量串,空串表示无法解码 dupID uint32 // 命中近重复时的参考图 id isDup uint8 // 是否标记为近重复(供审核留痕) + size int64 // 原图源文件字节数,随结果带回供落库写入 image_size } // slowImageThreshold 单张图各阶段合计超过该阈值时单独打一条告警,便于从大量图里定位异常慢图。 @@ -731,6 +736,7 @@ type downloadResult struct { download time.Duration // 本张下载耗时 phash time.Duration // 本张指纹计算耗时 upload time.Duration // 本张上传耗时 + size int64 // 原图源文件字节数(io.ReadAll 读到的下载响应体长度) err error // 下载或上传失败原因(非空即该张失败) } @@ -796,6 +802,7 @@ func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string) return res } res.download = time.Since(dlStart) + res.size = int64(len(data)) ext := path.Ext(u) if ext == "" || len(ext) > 5 { diff --git a/internal/service/street_snap_service.go b/internal/service/street_snap_service.go index 8b96427..57a43c9 100644 --- a/internal/service/street_snap_service.go +++ b/internal/service/street_snap_service.go @@ -37,6 +37,8 @@ type StreetSnapService interface { SetCover(ctx context.Context, snapID uint32, image string) error // SetDeleted 上下架:deleted=1 下架,0 上架。 SetDeleted(ctx context.Context, id uint32, deleted uint8) error + // SumImageSize 返回某条街拍全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。 + SumImageSize(ctx context.Context, snapID uint32) (int64, error) // PurgeGallery 彻底下架街拍:级联软删图片 + 下架 + 异步入队清理S4孤儿图。 PurgeGallery(ctx context.Context, id uint32) error } @@ -406,6 +408,11 @@ func (s *streetSnapService) SetCover(ctx context.Context, snapID uint32, image s return s.repo.SetSnapCover(ctx, snapID, image) } +// SumImageSize 返回某条街拍全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。 +func (s *streetSnapService) SumImageSize(ctx context.Context, snapID uint32) (int64, error) { + return s.repo.SumImageSize(ctx, snapID) +} + func (s *streetSnapService) SetDeleted(ctx context.Context, id uint32, deleted uint8) error { if deleted == 1 { // 下架街拍:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。