This commit is contained in:
toom1996
2026-09-29 19:59:30 +08:00
parent d15d2a4701
commit 361ffc01af
28 changed files with 896 additions and 47 deletions

58
.env.example Normal file
View File

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

View File

@ -62,6 +62,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath"
"sort" "sort"
"strings" "strings"
"time" "time"
@ -111,6 +112,8 @@ func main() {
runFixDup(os.Args[2:]) runFixDup(os.Args[2:])
case "purge-rejected": case "purge-rejected":
runPurgeRejected(os.Args[2:]) runPurgeRejected(os.Args[2:])
case "migrate":
runMigrate(os.Args[2:])
default: default:
usage() usage()
os.Exit(2) os.Exit(2)
@ -131,6 +134,11 @@ func usage() {
把现存「已驳回(rejected)」且未下架的记录转删除:级联软删其全部图片、记录置 把现存「已驳回(rejected)」且未下架的记录转删除:级联软删其全部图片、记录置
is_deleted=1(后台即消失),并把图片 key 入队 media_cleanup,由运行中的 worker is_deleted=1(后台即消失),并把图片 key 入队 media_cleanup,由运行中的 worker
按引用计数清理 S4 孤儿文件(与后台点「删除(清空 S4)」等价,但一次性全局处理) 按引用计数清理 S4 孤儿文件(与后台点「删除(清空 S4)」等价,但一次性全局处理)
dbtool migrate [-config <yml] [-in <file.sql>]
执行 db/migrations 下的迁移脚本(按文件名排序、逐文件执行、幂等可重跑):
每个文件执行成功后记入 schema_migrations,重跑已记录的会跳过;库内「已存在」
视为已应用(也记入记录表后跳过),因此历史已手动 psql 过的迁移重跑不会报错中断。
-in 指定单个文件时,只执行该文件。本子命令替代 psql,免装客户端即可落库。
说明: 说明:
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。 连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
@ -405,6 +413,110 @@ func runPurgeRejected(args []string) {
log.Printf("✓ 完成:转删除记录 %d 条、图片 %d 张,已入队 media_cleanup(运行中 worker 将按引用计数清理 S4)", totalRecs, totalKeys) 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 的查询。 // selectUint32s 执行返回单列 uint32 的查询。
func selectUint32s(db *sql.DB, q string, args ...any) ([]uint32, error) { func selectUint32s(db *sql.DB, q string, args ...any) ([]uint32, error) {
rows, err := db.Query(q, args...) rows, err := db.Query(q, args...)

View File

@ -8,7 +8,7 @@ import (
"context" "context"
"errors" "errors"
"flag" "flag"
"log" "log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@ -41,6 +41,19 @@ func s4BaseURL(cfgBase string, up *storage.S4Uploader) string {
return cfgBase 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() { func main() {
configPath := flag.String("config", "", "配置文件路径,默认查找 configs/config.yml") configPath := flag.String("config", "", "配置文件路径,默认查找 configs/config.yml")
flag.Parse() flag.Parse()
@ -48,35 +61,46 @@ func main() {
// 1. 加载配置(yml 为主,环境变量可覆盖) // 1. 加载配置(yml 为主,环境变量可覆盖)
cfg, err := config.Load(*configPath) cfg, err := config.Load(*configPath)
if err != nil { if err != nil {
log.Fatalf("✗ 加载配置失败: %v", err) slog.Error("加载配置失败", "error", err)
os.Exit(1)
} }
if from := cfg.LoadedFrom(); from != "" { // 缺失的必填/推荐敏感配置:本地 TTY 交互提示补填(写回 config.local.yml),
log.Println("✓ 配置文件:", from) // 容器/CI 等非交互环境缺失必填项则直接报错退出并提示用环境变量注入。
} else { if err := config.PromptMissing(cfg); err != nil {
log.Println("! 未找到配置文件,使用默认值与环境变量") slog.Error("启动前校验失败", "error", err)
os.Exit(1)
} }
gin.SetMode(cfg.Server.Mode) gin.SetMode(cfg.Server.Mode)
setupLogger(cfg.Server.Mode)
if from := cfg.LoadedFrom(); from != "" {
slog.Info("配置文件", "path", from)
} else {
slog.Warn("未找到配置文件,使用默认值与环境变量")
}
// 2. 连接数据库 // 2. 连接数据库
db, err := database.New(cfg.Database) db, err := database.New(cfg.Database)
if err != nil { if err != nil {
log.Fatalf("✗ %v", err) slog.Error("数据库连接失败", "error", err)
os.Exit(1)
} }
defer func() { defer func() {
if err := database.Close(db); err != nil { if err := database.Close(db); err != nil {
log.Printf("! 关闭数据库连接失败: %v", err) slog.Error("关闭数据库连接失败", "error", err)
} }
}() }()
// 2.5 结构不再随启动自动迁移:全库「结构 + 索引 + 数据」统一由 cmd/dbtool 导出的 // 2.5 结构不再随启动自动迁移:全库「结构 + 索引 + 数据」统一由 cmd/dbtool 导出的
// 纯 SQL 维护(dbtool dump → psql -f)。这里只探活,不执行任何 DDL。 // 纯 SQL 维护(dbtool dump → psql -f)。这里只探活,不执行任何 DDL。
log.Println("✓ PostgreSQL 已连接:", cfg.Database.Addr()) slog.Info("PostgreSQL 已连接", "addr", cfg.Database.Addr())
// 3. 确保上传目录存在(静态文件服务的根目录) // 3. 确保上传目录存在(静态文件服务的根目录)
uploadDir, _ := filepath.Abs(cfg.Upload.Dir) uploadDir, _ := filepath.Abs(cfg.Upload.Dir)
if err := os.MkdirAll(uploadDir, 0o755); err != nil { 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)。 // 4.5 初始化公开 ID 混淆(HashID)。
// 对外接口的 id / brand_id 一律用 hashid 编码串,内部仍用数字主键; // 对外接口的 id / brand_id 一律用 hashid 编码串,内部仍用数字主键;
@ -137,9 +161,9 @@ func main() {
// free 走公开 CoreIX 样式、VIP 走预签名原图,既方便换域名又能做分级(见 internal/pkg/imgurl)。 // free 走公开 CoreIX 样式、VIP 走预签名原图,既方便换域名又能做分级(见 internal/pkg/imgurl)。
imgUp = s4Up imgUp = s4Up
imgDel = 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 { } else {
log.Printf("✓ 图片存储: 本地 %s", cfg.Upload.URLPrefix) slog.Info("图片存储(本地)", "prefix", cfg.Upload.URLPrefix)
} }
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。 // 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。 // 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
@ -155,9 +179,9 @@ func main() {
Brand: handler.NewBrandHandler(brandSvc), Brand: handler.NewBrandHandler(brandSvc),
StreetSnap: handler.NewStreetSnapHandler(snapSvc, favSvc), StreetSnap: handler.NewStreetSnapHandler(snapSvc, favSvc),
Auth: handler.NewAuthHandler(authSvc), Auth: handler.NewAuthHandler(authSvc),
Favorite: handler.NewFavoriteHandler(favSvc), Favorite: handler.NewFavoriteHandler(favSvc),
History: handler.NewHistoryHandler(histSvc), History: handler.NewHistoryHandler(histSvc),
Health: handler.NewHealthHandler(), Health: handler.NewHealthHandler(),
}) })
// 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。 // 5.5 装配管理后台(Backstage)handler,供下方独立引擎使用。
@ -172,9 +196,10 @@ func main() {
} }
go func() { 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) { 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() { 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) { 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,优雅关闭时随主流程一起退出。 // 用独立可取消 ctx,优雅关闭时随主流程一起退出。
workerCtx, workerCancel := context.WithCancel(context.Background()) workerCtx, workerCancel := context.WithCancel(context.Background())
go ingestSvc.Run(workerCtx, 2, 3*time.Second) go ingestSvc.Run(workerCtx, 2, 3*time.Second)
log.Printf("⚙ 入库 worker 已启动(队列 ingest_jobs)") slog.Info("入库 worker 已启动", "queue", "ingest_jobs")
srvBackstage := &http.Server{ srvBackstage := &http.Server{
Addr: ":" + cfg.Server.BackstagePort, Addr: ":" + cfg.Server.BackstagePort,
Handler: backstageEngine, Handler: backstageEngine,
} }
go func() { 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) { 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) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit <-quit
log.Println("→ 正在关闭服务 ...") slog.Info("正在关闭服务")
ctx, cancel := context.WithTimeout(context.Background(), ctx, cancel := context.WithTimeout(context.Background(),
time.Duration(cfg.Server.ShutdownTimeout)*time.Second) time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
defer cancel() defer cancel()
workerCancel() // 通知入库 worker 停止领取新任务 workerCancel() // 通知入库 worker 停止领取新任务
if err := srv.Shutdown(ctx); err != nil { if err := srv.Shutdown(ctx); err != nil {
log.Printf("! 公开服务优雅关闭超时: %v", err) slog.Error("公开服务优雅关闭超时", "error", err)
} }
if err := srvSSG.Shutdown(ctx); err != nil { if err := srvSSG.Shutdown(ctx); err != nil {
log.Printf("! SSG 内部服务优雅关闭超时: %v", err) slog.Error("SSG 内部服务优雅关闭超时", "error", err)
} }
if err := srvBackstage.Shutdown(ctx); err != nil { if err := srvBackstage.Shutdown(ctx); err != nil {
log.Printf("! 管理后台优雅关闭超时: %v", err) slog.Error("管理后台优雅关闭超时", "error", err)
} }
log.Println("✓ 服务已停止") slog.Info("服务已停止")
} }

View File

@ -1,9 +1,13 @@
# ============================================================ # ============================================================
# Fashion API 配置文件(唯一配置源) # Fashion API 配置文件(唯一配置源,但敏感项一律留空)
# #
# 加载优先级:环境变量 > 本文件 > 代码内置默认值 # 加载优先级:环境变量 > 本文件 > config.local.yml(本机私有)> 交互提示(仅本地 TTY)
# - 本地开发:直接改本文件即可,无需任何环境变量 # - 本地开发:终端启动时会交互式提示填写缺失的必填/推荐密钥,并自动写入 config.local.yml
# - 容器部署:保持本文件为默认值,用环境变量覆盖敏感项(见每项 env 注释) # - 容器部署:保持本文件为默认值,用环境变量(见 .env.example)覆盖敏感项
#
# ⚠️ 重要:JWT_SECRET / DB_PASSWORD / INGEST_SECRET / S4_AK / S4_SK 等敏感值
# 严禁写入本文件(会被提交进版本库)。本文件已将它们留空,请从环境变量或
# 本地交互提示获取。S4 的旧 AK/SK 曾误提交,已轮换并清空,请勿再填回明文。
# #
# 配置文件路径可通过 -config 启动参数或 CONFIG_PATH 环境变量指定, # 配置文件路径可通过 -config 启动参数或 CONFIG_PATH 环境变量指定,
# 默认按 ./configs/config.yml → ./config.yml 顺序查找。 # 默认按 ./configs/config.yml → ./config.yml 顺序查找。
@ -22,7 +26,8 @@ database:
host: 127.0.0.1 # env: DB_HOST host: 127.0.0.1 # env: DB_HOST
port: 5432 # env: DB_PORT port: 5432 # env: DB_PORT
user: fashion # env: DB_USER 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 容器 # 本地开发库名 fashion;对应 scripts/pgvector 的 Docker PostgreSQL 容器
name: fashion # env: DB_NAME name: fashion # env: DB_NAME
# GORM 日志级别:silent | error | warn | info # GORM 日志级别:silent | error | warn | info
@ -34,8 +39,8 @@ database:
conn_max_idle_time: 60 # 秒,空闲连接回收时间,防止池子持有被服务端回收的死连接导致 invalid connection conn_max_idle_time: 60 # 秒,空闲连接回收时间,防止池子持有被服务端回收的死连接导致 invalid connection
jwt: jwt:
# 生产环境务必用环境变量覆盖为随机长字符串 # ⚠️ 留空:生产经 JWT_SECRET 注入随机长字符串,或本地启动时交互填写
secret: dev-secret-change-me-fashion-2026 # env: JWT_SECRET secret: "" # env: JWT_SECRET
# token 有效期(小时),168 = 7 天,与原项目一致 # token 有效期(小时),168 = 7 天,与原项目一致
expire_hours: 168 expire_hours: 168
@ -47,9 +52,11 @@ upload:
url_prefix: /uploads url_prefix: /uploads
cors: cors:
# 允许的来源,"*" 表示全部(与原项目行为一致) # 允许的来源:生产必须收窄为具体域名列表(不要用 "*",否则任意站点可跨域调用)。
# 也可用环境变量 CORS_ALLOW_ORIGINS=https://a.com,https://b.com 在容器里覆盖本值。
allow_origins: allow_origins:
- "*" - "https://xisoa.com"
- "https://www.xisoa.com"
allow_methods: allow_methods:
- GET - GET
- POST - POST
@ -77,13 +84,21 @@ client_sign:
secret: "" # env: CLIENT_SIGN_SECRET secret: "" # env: CLIENT_SIGN_SECRET
ttl_seconds: 30 # 签名时间戳容忍窗口(秒),防重放;env: CLIENT_SIGN_TTL 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 签名上报。 # 爬虫入库管线:暴露 :8092 /admin/internal/ingest 接收爬虫 HMAC 签名上报。
# 设计:服务端到服务端(非前端 JS),密钥绝不下发前端、只存爬虫配置与 INGEST_SECRET,安全高。 # 设计:服务端到服务端(非前端 JS),密钥绝不下发前端、只存爬虫配置与 INGEST_SECRET,安全高。
# - secret 必须与爬虫端 config.yml 的 ingest.secret 完全一致;env INGEST_SECRET 注入随机长串(生产)。 # - secret 必须与爬虫端 config.yml 的 ingest.secret 完全一致;env INGEST_SECRET 注入随机长串(生产)。
# - 建表不再由服务启动自动完成:表结构随全库由 cmd/dbtool 导出的 SQL 维护(dbtool dump → psql -f)。 # - 建表不再由服务启动自动完成:表结构随全库由 cmd/dbtool 导出的 SQL 维护(dbtool dump → psql -f)。
# - 启用后 worker 随主进程拉起(cmd/server 内 goroutine),异步下载图、补 season_code、写正式表。 # - 启用后 worker 随主进程拉起(cmd/server 内 goroutine),异步下载图、补 season_code、写正式表。
ingest: 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 ttl_seconds: 300 # 签名时间戳容忍窗口(秒,±5min),防重放;env: INGEST_TTL
# 本机下载代理(仅本地开发用,不提交):worker 下载图片(如 assets.vogue.com)经此代理访问源站。 # 本机下载代理(仅本地开发用,不提交):worker 下载图片(如 assets.vogue.com)经此代理访问源站。
# 支持 http:// 或 socks5://,例如 socks5://127.0.0.1:51831 或 http://127.0.0.1:58591。 # 支持 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 权限) # - ak/sk:缤纷云子账户 AccessKey/SecretKey(须有 PutObject/DeleteObject/GetObject 权限)
# - style_display:全量展示图 CoreIX 样式名(VIP 与免费用户同质量)。须在控制台建一个公开样式 high, # - style_display:全量展示图 CoreIX 样式名(VIP 与免费用户同质量)。须在控制台建一个公开样式 high,
# 或填查询串(如 w=1080&q=80&fmt=webp)免建样式。代码含 "=" 即走查询串模式。 # 或填查询串(如 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 # 环境变量覆盖:S4_AK / S4_SK / S4_BUCKET / S4_ENDPOINT / S4_BASE_URL / S4_STYLE_DISPLAY / S4_STYLE_THUMB
# (旧 QINIU_* 仅 QINIU_STYLE_NORMAL 作 style_display 的兼容回退;S4_* 优先) # (旧 QINIU_* 仅 QINIU_STYLE_NORMAL 作 style_display 的兼容回退;S4_* 优先)
s4: s4:
enabled: true enabled: true
ak: "ur2YaIFZvySmjTIgGT1fxhAW" ak: "" # env: S4_AK(必填,启用 S4 时)
sk: "JUlfcQ4HNgO5eIzj9u7qaeQomqssvtI" sk: "" # env: S4_SK(必填,启用 S4 时)
bucket: "toomstudio" # 你的缤纷云 S4 桶名 bucket: "toomstudio" # 你的缤纷云 S4 桶名
endpoint: "https://s3.bitiful.net" endpoint: "https://s3.bitiful.net"
region: "cn-east-1" region: "cn-east-1"
base_url: "" # 留空自动推导 https://toomstudio.s3.bitiful.net base_url: "" # 留空自动推导 https://toomstudio.s3.bitiful.net
style_display: "high" # 高清公开样式名(须在缤纷云控制台建 public 样式 high);或填查询串 w=1080&q=80&fmt=webp style_display: "high" # 高清公开样式名(须在缤纷云控制台建 public 样式 high);或填查询串 w=1080&q=80&fmt=webp
style_thumb: "thumb" # 列表缩略图公开样式名(控制台建 public 样式 thumb);仅用于列表/卡片等小尺寸场景,详情页与大图仍用 style_display。留空则回落 style_display style_thumb: "thumb" # 列表缩略图公开样式名(控制台建 public 样式 thumb);仅用于列表/卡片等小尺寸场景,详情页与大图仍用 style_display。留空则回落 style_display

View File

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

2
go.mod
View File

@ -14,6 +14,7 @@ require (
golang.org/x/crypto v0.54.0 golang.org/x/crypto v0.54.0
gorm.io/driver/postgres v1.6.3 gorm.io/driver/postgres v1.6.3
gorm.io/gorm v1.31.2 gorm.io/gorm v1.31.2
github.com/mattn/go-isatty v0.0.20
) )
require ( require (
@ -49,7 +50,6 @@ require (
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect

View File

@ -22,6 +22,8 @@ type Config struct {
Upload UploadConfig `yaml:"upload"` Upload UploadConfig `yaml:"upload"`
CORS CORSConfig `yaml:"cors"` CORS CORSConfig `yaml:"cors"`
ClientSign ClientSignConfig `yaml:"client_sign"` ClientSign ClientSignConfig `yaml:"client_sign"`
// RateLimit 按客户端 IP 的速率限制(兜底防刷,与 client_sign 配合)。
RateLimit RateLimitConfig `yaml:"rate_limit"`
// Ingest 爬虫上报接口的 HMAC 验签配置(服务端到服务端,密钥不下发前端)。 // Ingest 爬虫上报接口的 HMAC 验签配置(服务端到服务端,密钥不下发前端)。
Ingest IngestConfig `yaml:"ingest"` Ingest IngestConfig `yaml:"ingest"`
// S4 缤纷云对象存储配置(S3 兼容,爬虫入库图片上传目标)。 // S4 缤纷云对象存储配置(S3 兼容,爬虫入库图片上传目标)。
@ -171,6 +173,13 @@ type ClientSignConfig struct {
TTLSeconds int `yaml:"ttl_seconds"` 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 缺字段时服务仍可启动。 // defaultConfig 返回内置默认值,保证 yml 缺字段时服务仍可启动。
func defaultConfig() *Config { func defaultConfig() *Config {
return &Config{ return &Config{
@ -215,6 +224,11 @@ func defaultConfig() *Config {
Secret: "", Secret: "",
TTLSeconds: 30, TTLSeconds: 30,
}, },
RateLimit: RateLimitConfig{
Enabled: false,
RPS: 20,
Burst: 40,
},
Ingest: IngestConfig{ Ingest: IngestConfig{
Secret: "", Secret: "",
TTLSeconds: 300, TTLSeconds: 300,
@ -332,6 +346,20 @@ func (c *Config) applyEnv() {
envStr("CLIENT_SIGN_SECRET", &c.ClientSign.Secret) envStr("CLIENT_SIGN_SECRET", &c.ClientSign.Secret)
envInt("CLIENT_SIGN_TTL", &c.ClientSign.TTLSeconds) 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_DIR", &c.Upload.Dir)
envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix) envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix)
@ -383,6 +411,12 @@ func (c *Config) normalize() {
if c.JWT.RefreshExpireHours <= 0 { if c.JWT.RefreshExpireHours <= 0 {
c.JWT.RefreshExpireHours = 720 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 == "" { if c.Upload.Dir == "" {
c.Upload.Dir = "./uploads" c.Upload.Dir = "./uploads"
} }

212
internal/config/prompt.go Normal file
View File

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

View File

@ -249,7 +249,8 @@ func (h *BackstageHandler) RunwayDetail(c *gin.Context) {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "走秀不存在或已删除: " + raw})
return 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(读基表,含待审)。 // StreetSnaps 街拍管理列表:按年份/城市/排序筛选,复用 StreetSnapService.AdminList(读基表,含待审)。
@ -305,7 +306,8 @@ func (h *BackstageHandler) StreetSnapDetail(c *gin.Context) {
renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw}) renderPage(c, http.StatusNotFound, "error", gin.H{"Error": "街拍不存在或已删除: " + raw})
return 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)。 // 批量统计每篇图集的重复率(基于 is_duplicate 列,阈值 phash.DefaultThreshold=4)。
dupStats := h.dupStatsByCards(c.Request.Context(), cards) dupStats := h.dupStatsByCards(c.Request.Context(), cards)
// 批量统计每篇图集的原图总大小(基于 image_size 列,worker 下载时写入源文件字节数)。
imageSizes := h.imageSizeByCards(c.Request.Context(), cards)
items := make([]gin.H, 0, len(cards)) items := make([]gin.H, 0, len(cards))
for _, card := range cards { for _, card := range cards {
var t byte var t byte
@ -347,6 +351,7 @@ func (h *BackstageHandler) Articles(c *gin.Context) {
"Title": card.Title, "Title": card.Title,
"Subtitle": card.Subtitle, "Subtitle": card.Subtitle,
"ImageCount": card.ImageCount, "ImageCount": card.ImageCount,
"ImageTotalSize": imageSizes[card.ID],
"Status": card.Status, "Status": card.Status,
"DupCount": ds.Dup, "DupCount": ds.Dup,
"DupPct": dupPct, "DupPct": dupPct,
@ -407,6 +412,36 @@ func (h *BackstageHandler) dupStatsByCards(ctx context.Context, cards []reposito
return out 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 通过:把记录状态置为已发布(单表模型:仅改状态,不重建图片)。 // ArticleApprove 通过:把记录状态置为已发布(单表模型:仅改状态,不重建图片)。
// 路由标识用 hashid,与编辑页一致;已发布 / 已驳回记录调用仍是幂等的。 // 路由标识用 hashid,与编辑页一致;已发布 / 已驳回记录调用仍是幂等的。
func (h *BackstageHandler) ArticleApprove(c *gin.Context) { func (h *BackstageHandler) ArticleApprove(c *gin.Context) {
@ -684,12 +719,17 @@ func (h *BackstageHandler) RunwayEdit(c *gin.Context) {
dupPct = float64(dupCount) / float64(n) * 100 dupPct = float64(dupCount) / float64(n) * 100
} }
dupSources := h.buildDupSources(c.Request.Context(), "runway", cells) 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{ renderPage(c, http.StatusOK, "runway-edit", gin.H{
"UID": raw, "UID": raw,
"Kind": "runway", "Kind": "runway",
"RecordID": rw.ID, "RecordID": rw.ID,
"Status": rw.Status, "Status": rw.Status,
"Runway": rw, "Runway": rw,
"ImageTotalSize": totalSize,
"Images": imgs, "Images": imgs,
"Cells": cells, "Cells": cells,
"BrandName": brandName, "BrandName": brandName,
@ -869,12 +909,17 @@ func (h *BackstageHandler) StreetSnapEdit(c *gin.Context) {
dupPct = float64(dupCount) / float64(n) * 100 dupPct = float64(dupCount) / float64(n) * 100
} }
dupSources := h.buildDupSources(c.Request.Context(), "street", cells) 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{ renderPage(c, http.StatusOK, "snap-edit", gin.H{
"UID": raw, "UID": raw,
"Kind": "street", "Kind": "street",
"RecordID": snap.ID, "RecordID": snap.ID,
"Status": snap.Status, "Status": snap.Status,
"Snap": snap, "Snap": snap,
"ImageTotalSize": totalSize,
"Images": imgs, "Images": imgs,
"Cells": cells, "Cells": cells,
"IsDown": snap.IsDeleted == 1, "IsDown": snap.IsDeleted == 1,

View File

@ -19,6 +19,7 @@ import (
"bytes" "bytes"
"context" "context"
"embed" "embed"
"fmt"
"html/template" "html/template"
"io/fs" "io/fs"
"net/http" "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/* //go:embed templates/layout.html templates/partials.html templates/pages/*.html assets/*
var backstageFS embed.FS var backstageFS embed.FS
@ -60,6 +80,7 @@ func init() {
// imgThumb 同但走列表缩略图样式(小尺寸场景省流量)。两者兜底逻辑见 imgurl.Composer。 // imgThumb 同但走列表缩略图样式(小尺寸场景省流量)。两者兜底逻辑见 imgurl.Composer。
"imgURL": func(stored string) string { return imgComposer.Compose(stored) }, "imgURL": func(stored string) string { return imgComposer.Compose(stored) },
"imgThumb": func(stored string) string { return imgComposer.ComposeThumb(stored) }, "imgThumb": func(stored string) string { return imgComposer.ComposeThumb(stored) },
"formatBytes": formatBytes,
} }
base, err := template.New("layout.html").Funcs(funcs). base, err := template.New("layout.html").Funcs(funcs).

View File

@ -21,7 +21,7 @@
<table> <table>
<thead><tr> <thead><tr>
<th style="width:30px"><input type="checkbox" id="sel-all" aria-label="全选本页"></th> <th style="width:30px"><input type="checkbox" id="sel-all" aria-label="全选本页"></th>
<th>类型</th><th>ID</th><th>标题</th><th>副标题</th><th>图数</th><th>重复率</th><th>状态</th><th>操作</th> <th>类型</th><th>ID</th><th>标题</th><th>副标题</th><th>图数</th><th>原图总大小</th><th>重复率</th><th>状态</th><th>操作</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{{range .Items}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}"> {{range .Items}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
@ -30,7 +30,7 @@
<td><code>{{.ID}}</code></td> <td><code>{{.ID}}</code></td>
<td>{{.Title}}</td> <td>{{.Title}}</td>
<td class="muted">{{.Subtitle}}</td> <td class="muted">{{.Subtitle}}</td>
<td>{{.ImageCount}}</td> <td>{{.ImageCount}}</td><td>{{formatBytes .ImageTotalSize}}</td>
<td>{{if .DupCount}}<span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}})</span>{{else}}<span class="muted">—</span>{{end}}</td> <td>{{if .DupCount}}<span class="badge dup">{{printf "%.0f" .DupPct}}%({{.DupCount}})</span>{{else}}<span class="muted">—</span>{{end}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td> <td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td> <td>

View File

@ -6,6 +6,7 @@
<div class="main"> <div class="main">
<p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p> <p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p>
<h1>{{.Article.Title}}</h1> <h1>{{.Article.Title}}</h1>
<p class="muted">原图总大小:{{formatBytes .ImageTotalSize}}</p>
<div class="grid"> <div class="grid">
{{range .Article.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{range .Detail}}<div class="cell det"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}} · 副图</span></div>{{end}}{{end}} {{range .Article.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{range .Detail}}<div class="cell det"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}} · 副图</span></div>{{end}}{{end}}
</div> </div>

View File

@ -5,6 +5,7 @@
{{template "bar" .}} {{template "bar" .}}
<div class="main"> <div class="main">
<p class="muted">品牌 {{.BrandName}}{{if .IsDown}} · <span class="down">已下架</span>{{end}}</p> <p class="muted">品牌 {{.BrandName}}{{if .IsDown}} · <span class="down">已下架</span>{{end}}</p>
<p class="muted">原图总大小:{{formatBytes .ImageTotalSize}}</p>
<form method="post" action="/admin/runways/{{.UID}}/edit"> <form method="post" action="/admin/runways/{{.UID}}/edit">
<div class="row"><div><label>英文标题</label><input name="title_en" value="{{.Runway.TitleEn}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Runway.TitleCn}}"></div></div> <div class="row"><div><label>英文标题</label><input name="title_en" value="{{.Runway.TitleEn}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Runway.TitleCn}}"></div></div>
<div class="row"><div><label>英文描述</label><textarea name="description_en">{{.Runway.DescriptionEn}}</textarea></div><div><label>中文描述</label><textarea name="description_cn">{{.Runway.DescriptionCn}}</textarea></div></div> <div class="row"><div><label>英文描述</label><textarea name="description_en">{{.Runway.DescriptionEn}}</textarea></div><div><label>中文描述</label><textarea name="description_cn">{{.Runway.DescriptionCn}}</textarea></div></div>

View File

@ -5,6 +5,7 @@
{{template "bar" .}} {{template "bar" .}}
<div class="main"> <div class="main">
<p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p> <p class="muted">{{if .IsDown}}<span class="down">已下架</span>{{end}}</p>
<p class="muted">原图总大小:{{formatBytes .ImageTotalSize}}</p>
<form method="post" action="/admin/street-snaps/{{.UID}}/edit"> <form method="post" action="/admin/street-snaps/{{.UID}}/edit">
<div class="row"><div><label>英文标题</label><input name="title" value="{{.Snap.Title}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Snap.TitleCn}}"></div></div> <div class="row"><div><label>英文标题</label><input name="title" value="{{.Snap.Title}}"></div><div><label>中文标题</label><input name="title_cn" value="{{.Snap.TitleCn}}"></div></div>
<div class="row"><div><label>年份</label><input name="year" value="{{.Snap.Year}}"></div><div><label>城市</label><input name="city" value="{{.Snap.City}}"></div></div> <div class="row"><div><label>年份</label><input name="year" value="{{.Snap.Year}}"></div><div><label>城市</label><input name="city" value="{{.Snap.City}}"></div></div>

View File

@ -6,6 +6,7 @@
<div class="main"> <div class="main">
<p class="muted">编码 {{.Snap.UID}}</p> <p class="muted">编码 {{.Snap.UID}}</p>
<h1>{{.Snap.Title}}</h1> <h1>{{.Snap.Title}}</h1>
<p class="muted">原图总大小:{{formatBytes .ImageTotalSize}}</p>
<div class="grid"> <div class="grid">
{{range .Snap.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{range .Detail}}<div class="cell det"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}} · 副图</span></div>{{end}}{{end}} {{range .Snap.Images}}<div class="cell"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}}</span></div>{{range .Detail}}<div class="cell det"><button class="thumb" type="button" data-zoom><img src="{{imgURL .Image}}" loading="lazy"></button><span>{{.Name}} · 副图</span></div>{{end}}{{end}}
</div> </div>

View File

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

View File

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

View File

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

View File

@ -20,6 +20,10 @@ type BrandRunwayImage struct {
// =1 为细节图,parent_image_id 指向同 runway 内主图的行 id。Look 序号对产品无意义,已移除。 // =1 为细节图,parent_image_id 指向同 runway 内主图的行 id。Look 序号对产品无意义,已移除。
ParentImageID uint32 `gorm:"column:parent_image_id;not null;default:0" json:"parent_image_id"` 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 留痕): // 去重字段(近重复 + 语义 embedding 留痕):
// phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索; // phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索;
// is_duplicate / dup_of 仅在命中近重复时标记,供人工审核留痕,不阻断入库。 // is_duplicate / dup_of 仅在命中近重复时标记,供人工审核留痕,不阻断入库。

View File

@ -50,6 +50,9 @@ type StreetSnapImage struct {
// is_detail=0 为主图(parent_image_id=0);=1 为副图,parent_image_id 指向同专辑内主图的行 id。 // 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"` 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"` 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 指定图片明细表名。 // TableName 指定图片明细表名。

View File

@ -39,6 +39,9 @@ type AdminRepository interface {
// DupRateByRecords 批量统计若干记录的图片去重情况(仅存活行)。 // DupRateByRecords 批量统计若干记录的图片去重情况(仅存活行)。
// 返回 owner id → {Total 存活图片总数, Dup is_duplicate=1 张数};不存在的 id 不出现在 map 中。 // 返回 owner id → {Total 存活图片总数, Dup is_duplicate=1 张数};不存在的 id 不出现在 map 中。
DupRateByRecords(ctx context.Context, kind string, ids []uint32) (map[uint32]DupStat, error) 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 { type adminRepository struct {
@ -148,6 +151,41 @@ func (r *adminRepository) DupRateByRecords(ctx context.Context, kind string, ids
return out, nil 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) { func (r *adminRepository) CountMixed(ctx context.Context, status, kind string) (int64, error) {
rw, rargs := legWhere("r", status) rw, rargs := legWhere("r", status)
sw, sargs := legWhere("s", status) sw, sargs := legWhere("s", status)

View File

@ -52,6 +52,8 @@ type ArticleRepository interface {
SetRunwayCover(ctx context.Context, runwayID uint32, image string) error SetRunwayCover(ctx context.Context, runwayID uint32, image string) error
// SetRunwayDeleted 上下架:deleted=1 下架(对外不可见),0 上架。 // SetRunwayDeleted 上下架:deleted=1 下架(对外不可见),0 上架。
SetRunwayDeleted(ctx context.Context, id uint32, deleted uint8) error SetRunwayDeleted(ctx context.Context, id uint32, deleted uint8) error
// SumImageSize 返回某条走秀全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。
SumImageSize(ctx context.Context, runwayID uint32) (int64, error)
} }
type articleRepository struct { type articleRepository struct {
@ -565,3 +567,14 @@ func (r *articleRepository) SetRunwayDeleted(ctx context.Context, id uint32, del
Where("id = ?", id). Where("id = ?", id).
Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error 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
}

View File

@ -54,6 +54,8 @@ type StreetSnapRepository interface {
SetSnapCover(ctx context.Context, snapID uint32, image string) error SetSnapCover(ctx context.Context, snapID uint32, image string) error
// SetSnapDeleted 上下架:deleted=1 下架,0 上架。 // SetSnapDeleted 上下架:deleted=1 下架,0 上架。
SetSnapDeleted(ctx context.Context, id uint32, deleted uint8) error SetSnapDeleted(ctx context.Context, id uint32, deleted uint8) error
// SumImageSize 返回某条街拍全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。
SumImageSize(ctx context.Context, snapID uint32) (int64, error)
} }
type streetSnapRepository struct { type streetSnapRepository struct {
@ -498,3 +500,14 @@ func (r *streetSnapRepository) SetSnapDeleted(ctx context.Context, id uint32, de
Where("id = ?", id). Where("id = ?", id).
Updates(map[string]any{"is_deleted": deleted, "updated_at": uint32(time.Now().Unix())}).Error 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
}

View File

@ -40,8 +40,11 @@ type Options struct {
// 注意:SSG 内部接口(/api/internal/ssg/*)不在这里注册,而是由 NewSSG 挂在独立的内部端口上, // 注意:SSG 内部接口(/api/internal/ssg/*)不在这里注册,而是由 NewSSG 挂在独立的内部端口上,
// 二者物理隔离,确保构建期全量数据不会从对外公开端口泄露。 // 二者物理隔离,确保构建期全量数据不会从对外公开端口泄露。
func New(opt Options) *gin.Engine { 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.CORS(opt.Config.CORS))
r.Use(middleware.RateLimit(opt.Config.RateLimit))
// 上传图片的静态服务。 // 上传图片的静态服务。
// 接口返回的 cover / image 是相对路径(如 /uploads/2026/08/xx.jpg), // 接口返回的 cover / image 是相对路径(如 /uploads/2026/08/xx.jpg),

View File

@ -40,6 +40,8 @@ type ArticleService interface {
SetCover(ctx context.Context, runwayID uint32, image string) error SetCover(ctx context.Context, runwayID uint32, image string) error
// SetDeleted 上下架:deleted=1 下架(对外不可见),0 上架。 // SetDeleted 上下架:deleted=1 下架(对外不可见),0 上架。
SetDeleted(ctx context.Context, id uint32, deleted uint8) error SetDeleted(ctx context.Context, id uint32, deleted uint8) error
// SumImageSize 返回某条走秀全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。
SumImageSize(ctx context.Context, runwayID uint32) (int64, error)
// PurgeGallery 彻底下架图集:级联软删图片 + 下架 + 异步入队清理S4孤儿图。 // PurgeGallery 彻底下架图集:级联软删图片 + 下架 + 异步入队清理S4孤儿图。
PurgeGallery(ctx context.Context, id uint32) error 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) 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 { func (s *articleService) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
if deleted == 1 { if deleted == 1 {
// 下架图集:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。 // 下架图集:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。

View File

@ -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 不应为空(上传应成功)")
}
}

View File

@ -341,6 +341,7 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
Phash: sqlNull(fi.phash), Phash: sqlNull(fi.phash),
IsDuplicate: fi.isDup, IsDuplicate: fi.isDup,
DupOf: strconv.FormatUint(uint64(fi.dupID), 10), 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), Phash: sqlNull(r.phashBits),
IsDuplicate: isDup, IsDuplicate: isDup,
DupOf: strconv.FormatUint(uint64(dupID), 10), DupOf: strconv.FormatUint(uint64(dupID), 10),
ImageSize: r.size,
}) })
} }
timing.images = len(rows) timing.images = len(rows)
@ -547,6 +549,7 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
Phash: sqlNull(fi.phash), Phash: sqlNull(fi.phash),
IsDuplicate: fi.isDup, IsDuplicate: fi.isDup,
DupOf: strconv.FormatUint(uint64(fi.dupID), 10), DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
ImageSize: fi.size,
}) })
} }
var saveErr error var saveErr error
@ -609,6 +612,7 @@ func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix,
phash: r.phashBits, phash: r.phashBits,
dupID: dupID, dupID: dupID,
isDup: isDup, isDup: isDup,
size: r.size,
}) })
} }
timing.images = len(out) timing.images = len(out)
@ -642,6 +646,7 @@ type fetchedImage struct {
phash string // dHash 的 pgvector 二进制向量串,空串表示无法解码 phash string // dHash 的 pgvector 二进制向量串,空串表示无法解码
dupID uint32 // 命中近重复时的参考图 id dupID uint32 // 命中近重复时的参考图 id
isDup uint8 // 是否标记为近重复(供审核留痕) isDup uint8 // 是否标记为近重复(供审核留痕)
size int64 // 原图源文件字节数,随结果带回供落库写入 image_size
} }
// slowImageThreshold 单张图各阶段合计超过该阈值时单独打一条告警,便于从大量图里定位异常慢图。 // slowImageThreshold 单张图各阶段合计超过该阈值时单独打一条告警,便于从大量图里定位异常慢图。
@ -731,6 +736,7 @@ type downloadResult struct {
download time.Duration // 本张下载耗时 download time.Duration // 本张下载耗时
phash time.Duration // 本张指纹计算耗时 phash time.Duration // 本张指纹计算耗时
upload time.Duration // 本张上传耗时 upload time.Duration // 本张上传耗时
size int64 // 原图源文件字节数(io.ReadAll 读到的下载响应体长度)
err error // 下载或上传失败原因(非空即该张失败) err error // 下载或上传失败原因(非空即该张失败)
} }
@ -796,6 +802,7 @@ func (s *IngestService) downloadAndUpload(ctx context.Context, u, prefix string)
return res return res
} }
res.download = time.Since(dlStart) res.download = time.Since(dlStart)
res.size = int64(len(data))
ext := path.Ext(u) ext := path.Ext(u)
if ext == "" || len(ext) > 5 { if ext == "" || len(ext) > 5 {

View File

@ -37,6 +37,8 @@ type StreetSnapService interface {
SetCover(ctx context.Context, snapID uint32, image string) error SetCover(ctx context.Context, snapID uint32, image string) error
// SetDeleted 上下架:deleted=1 下架,0 上架。 // SetDeleted 上下架:deleted=1 下架,0 上架。
SetDeleted(ctx context.Context, id uint32, deleted uint8) error SetDeleted(ctx context.Context, id uint32, deleted uint8) error
// SumImageSize 返回某条街拍全部存活图片的原图字节数之和(供后台「图集原图总大小」展示)。
SumImageSize(ctx context.Context, snapID uint32) (int64, error)
// PurgeGallery 彻底下架街拍:级联软删图片 + 下架 + 异步入队清理S4孤儿图。 // PurgeGallery 彻底下架街拍:级联软删图片 + 下架 + 异步入队清理S4孤儿图。
PurgeGallery(ctx context.Context, id uint32) error 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) 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 { func (s *streetSnapService) SetDeleted(ctx context.Context, id uint32, deleted uint8) error {
if deleted == 1 { if deleted == 1 {
// 下架街拍:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。 // 下架街拍:软删图片 + 置下架 + 异步入队清理S4孤儿图(见 PurgeGallery)。