update
This commit is contained in:
58
.env.example
Normal file
58
.env.example
Normal 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
|
||||
@ -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 <yml] [-in <file.sql>]
|
||||
执行 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...)
|
||||
|
||||
@ -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 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
|
||||
@ -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("服务已停止")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
8
db/migrations/2026-09-29-01-image-original-size.sql
Normal file
8
db/migrations/2026-09-29-01-image-original-size.sql
Normal 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
2
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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
212
internal/config/prompt.go
Normal file
212
internal/config/prompt.go
Normal 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())
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
<table>
|
||||
<thead><tr>
|
||||
<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>
|
||||
<tbody>
|
||||
{{range .Items}}<tr data-kind="{{.Kind}}" data-id="{{.ID}}">
|
||||
@ -30,7 +30,7 @@
|
||||
<td><code>{{.ID}}</code></td>
|
||||
<td>{{.Title}}</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><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td>
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
<div class="main">
|
||||
<p class="muted">编码 {{.Article.UID}} · 品牌 {{.Article.BrandName}}</p>
|
||||
<h1>{{.Article.Title}}</h1>
|
||||
<p class="muted">原图总大小:{{formatBytes .ImageTotalSize}}</p>
|
||||
<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}}
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
{{template "bar" .}}
|
||||
<div class="main">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
{{template "bar" .}}
|
||||
<div class="main">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
<div class="main">
|
||||
<p class="muted">编码 {{.Snap.UID}}</p>
|
||||
<h1>{{.Snap.Title}}</h1>
|
||||
<p class="muted">原图总大小:{{formatBytes .ImageTotalSize}}</p>
|
||||
<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}}
|
||||
</div>
|
||||
|
||||
25
internal/handler/templates_format_test.go
Normal file
25
internal/handler/templates_format_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
53
internal/middleware/ginlogger.go
Normal file
53
internal/middleware/ginlogger.go
Normal 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...)
|
||||
}
|
||||
}
|
||||
}
|
||||
97
internal/middleware/ratelimit.go
Normal file
97
internal/middleware/ratelimit.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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 仅在命中近重复时标记,供人工审核留痕,不阻断入库。
|
||||
|
||||
@ -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 指定图片明细表名。
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)。
|
||||
|
||||
43
internal/service/ingest_image_size_test.go
Normal file
43
internal/service/ingest_image_size_test.go
Normal 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 不应为空(上传应成功)")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)。
|
||||
|
||||
Reference in New Issue
Block a user