This commit is contained in:
toom1996
2026-09-17 22:51:00 +08:00
parent 766c691eb3
commit f7ae917603
33 changed files with 8181 additions and 3094 deletions

View File

@ -8,13 +8,13 @@ build:
run: build
./bin/server
# 结构迁移为手动 SQL(无自动运行器):db/migrations/001-007(ingest/review 链)
# 与 scripts/sql/002-008(i18n/账号链)内容互补,按文件名顺序用 mysql 客户端执行,例如:
# mysql db_dev < db/migrations/001_create_street_snap.sql
# mysql db_dev < db/migrations/002_ingest.sql
# ... 两套链都跑完才是完整 schema
# 表结构由 GORM AutoMigrate 在 server 启动时幂等创建,无需手动 SQL。
# 跨环境搬运全库数据(结构 + 数据)用 dbtool:
# go run ./cmd/dbtool dump -out db_dump.json
# go run ./cmd/dbtool import -in db_dump.json # 整机重建
# go run ./cmd/dbtool import -in db_dump.json -data-only # 仅导数据(结构已由 AutoMigrate 建好)
migrate:
@echo "Migrations are manual SQL. Apply db/migrations/001-007 then scripts/sql/002-008 via a mysql client."
@echo "Schema is auto-migrated by server on startup (GORM AutoMigrate). Use 'go run ./cmd/dbtool' to move data between environments."
# 跑单测
test:

View File

@ -1,6 +1,6 @@
# Fashion API
秀场 / 品牌档案对外只读 API 服务(Go + Gin + GORM/MySQL)。
秀场 / 品牌档案对外只读 API 服务(Go + Gin + GORM/PostgreSQL)。
本项目是从原 `admin/backend`(后台管理接口)中**剥离对外只读接口**后独立出的服务,
专门服务于前端项目 `test/test`(`D:\project\test\test`)。它只保留前端实际调用的公开接口,
@ -53,11 +53,11 @@
```
cmd/ 组合根(Composition Root):只做依赖装配,不含业务逻辑
server/ 启动 HTTP 服务
migrate/ 独立结构迁移命令
dbtool/ 跨环境数据搬运(dump/import)
internal/ 业务代码(不对外暴露)
config/ 配置加载(yml + 环境变量覆盖 + 默认值)
model/ 实体定义(gorm tag + json tag,PasswordHash 永不出参)
database/ MySQL 连接池 / 迁移
database/ PostgreSQL 连接池 / 自动迁移(AutoMigrate)
repository/ 数据访问(接口 + GORM 实现,查询条件用 Scope 安全组合)
service/ 业务逻辑(JWT 签发、密码哈希、参数归一化、聚合)
dto/ 请求/响应结构体与边界常量
@ -75,8 +75,8 @@ internal/ 业务代码(不对外暴露)
避免原项目“复用同一个 `*gorm.DB` 实例导致 WHERE 串味”的隐患。
- **排序字段白名单**:`orderBy` 只允许白名单内的值,杜绝 SQL 注入。
- **配置统一收敛到 yml**:见第 3 节。
- **迁移从启动流程剥离**:`auto_migrate` 默认 `false`,改用独立 `cmd/migrate` 命令显式执行,
避免每次启动都对 `brand_runway_images`(89 万行)等大表隐式 `ALTER TABLE`。
- **表结构由 GORM AutoMigrate 托管**:服务启动时幂等创建/更新全部表(替代原 `scripts/sql`、`db/migrations` 下的手写 MySQL 迁移脚本),
跨环境数据搬运经 `cmd/dbtool`(dump/import)。如需避免大表启动期加列,可在部署流程中先行 `dbtool import` 建好结构。
- **修复原项目配置隐患**:原 `admin/backend` 代码只读 `DB_PASS`,而 docker-compose 注入的是
`DB_PASSWORD`,二者不一致;本项目同时兼容 `DB_PASSWORD` 与 `DB_PASS`,`DB_PASSWORD` 优先。
@ -101,7 +101,7 @@ internal/ 业务代码(不对外暴露)
| `GIN_MODE` | server.mode |
| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_NAME` | 数据库连接 |
| `DB_PASSWORD`(或 `DB_PASS`) | database.password |
| `DB_LOG_LEVEL` / `DB_AUTO_MIGRATE` | GORM 日志 / 自动迁移开关 |
| `DB_LOG_LEVEL` | GORM 日志级别 |
| `JWT_SECRET` / `JWT_EXPIRE_HOURS` | 令牌密钥 / 有效期 |
| `UPLOAD_DIR` / `UPLOAD_URL_PREFIX` | 静态资源目录 / 前缀 |
| `CONFIG_PATH` | 显式指定配置文件路径(优先级高于 `-config` 参数) |
@ -118,24 +118,26 @@ internal/ 业务代码(不对外暴露)
### 前置要求
- Go 1.26+
- MySQL 8.x(本项目对接 WSL 1Panel 本地库:`127.0.0.1:3306`,库名 `db`,账号 `root/root`)
- PostgreSQL 16(+ pgvector,见 `scripts/pgvector` 的 Docker 一键起库):`127.0.0.1:5432`,库名 `fashion`,账号 `fashion/fashion_dev_2026`
### 构建
```bash
# 两个可执行文件:server(API 服务)与 migrate(结构迁移)
# 主服务
go build -o bin/server ./cmd/server
go build -o bin/migrate ./cmd/migrate
# 跨环境数据搬运工具(dump / import)
go build -o bin/dbtool ./cmd/dbtool
```
### 数据库迁移(可选,首次部署时执行一次)
### 数据库迁移(结构自动创建)
迁移默认**不**随服务启动执行(`auto_migrate: false`)。需建表/加字段时:
表结构**随服务启动自动执行** GORM AutoMigrate(幂等,仅增量变更,不重建已有表)。
跨环境搬运全库数据(结构 + 数据)用 dbtool:
```bash
./bin/migrate # 读取 configs/config.yml
# 或指定配置文件
./bin/migrate -config /path/to/config.yml
go run ./cmd/dbtool dump -out db_dump.json # 导出当前库
go run ./cmd/dbtool import -in db_dump.json # 在目标环境重建并回灌
go run ./cmd/dbtool import -in db_dump.json -data-only # 仅导数据(结构已由 AutoMigrate 建好)
```
### 启动服务
@ -228,11 +230,11 @@ backend/
│ └── config.yml # 唯一配置源
├── cmd/
│ ├── server/main.go # 组合根:启动 HTTP 服务 + 优雅关闭
│ └── migrate/main.go # 组合根:独立结构迁移
│ └── dbtool/main.go # 组合根:跨环境数据搬运(dump/import)
├── internal/
│ ├── config/ # 配置加载(yml + env + 默认值)
│ ├── model/ # 实体(brand / runway / runway_image / user)
│ ├── database/ # MySQL 连接池 + 迁移
│ ├── database/ # PostgreSQL 连接池 + 自动迁移
│ ├── repository/ # 数据访问(接口 + GORM 实现)
│ ├── service/ # 业务逻辑
│ ├── dto/ # 请求/响应结构体与边界常量

79
cmd/dbdiag/main.go Normal file
View File

@ -0,0 +1,79 @@
// 临时诊断:对照测试列元信息查询的"参数版"与"内联版",定位 42601。
package main
import (
"database/sql"
"fmt"
"log"
"strings"
_ "github.com/jackc/pgx/v5/stdlib"
"fashionapi/internal/config"
)
func main() {
cfg, err := config.Load("")
if err != nil {
log.Fatalf("load config: %v", err)
}
db, err := sql.Open("pgx", cfg.Database.DSN())
if err != nil {
log.Fatalf("open: %v", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("ping: %v", err)
}
base := `
SELECT c.column_name,
c.udt_name,
(c.is_nullable = 'YES'),
COALESCE(c.column_default, ''),
(c.column_default LIKE 'nextval(%')
FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.table_name = %s
ORDER BY c.ordinal_position`
// (a) 参数版:用 $1
paramQ := strings.Replace(base, "%s", "$1", 1)
fmt.Println("=== (a) 参数版 $1 ===")
testQ(db, paramQ, "brand")
// (b) 内联版:把 'brand' 直接拼进 SQL(模拟 simple-protocol 内联)
inlineQ := strings.Replace(base, "%s", "'brand'", 1)
fmt.Println("=== (b) 内联版 'brand' ===")
testQ(db, inlineQ)
// (c) 去掉 LIKE 表达式的版本(用 is_identity 代替)
noLike := `
SELECT c.column_name,
c.udt_name,
(c.is_nullable = 'YES'),
COALESCE(c.column_default, ''),
(c.is_identity = 'YES')
FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.table_name = 'brand'
ORDER BY c.ordinal_position`
fmt.Println("=== (c) 去掉 LIKE 表达式版 ===")
testQ(db, noLike)
}
func testQ(db *sql.DB, q string, args ...any) {
rows, err := db.Query(q, args...)
if err != nil {
fmt.Printf(" ERROR: %v\n", err)
// 尝试用 pgconn 解析 position
var pgErr interface{ Error() string }
_ = pgErr
fmt.Printf(" raw err: %s\n", err.Error())
return
}
cnt := 0
for rows.Next() {
cnt++
}
fmt.Printf(" OK rows=%d err=%v\n", cnt, rows.Err())
rows.Close()
}

File diff suppressed because it is too large Load Diff

BIN
cmd/dbtool/dbtool_bin.exe Normal file

Binary file not shown.

View File

@ -1,14 +1,14 @@
// Command dbtool 是后台管理用的数据库一键迁移脚本。
//
// 用途:在不同开发电脑之间搬运 MySQL 库(db_dev)的「结构 + 数据」,
// 省去手动 mysqldump / 重新 seed 的麻烦。完全复用后端既有的 mysql 驱动与
// configs/config.yml,不依赖任何外部二进制(mysqldump 等)。
// 用途:在不同开发电脑 / 环境之间搬运 PostgreSQL 库(fashion)的「结构 + 数据」,
// 省去手动 pg_dump / 重新 seed 的麻烦。完全复用后端既有的 pgx 驱动与
// configs/config.yml,不依赖任何外部二进制(pg_dump 等)。
//
// 子命令:
//
// dbtool dump -out db_dump.json 导出当前库全部表(schema+data)为单个 JSON 文件
// dbtool import -in db_dump.json 读取 JSON 文件,DROP+CREATE+INSERT 回灌到目标库
// (可用 -data-only 只导数据,表结构需已存在)
// (可用 -data-only 只导数据,表结构须已由 AutoMigrate 创建)
//
// 连接信息来自 configs/config.yml 的 database 段(同后端服务),可用 -config 指定其它配置。
package main
@ -16,6 +16,7 @@ package main
import (
"database/sql"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
@ -23,16 +24,26 @@ import (
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib"
"fashionapi/internal/config"
)
// columnMeta 是单列的元信息,用于重建 DDL 与导入时类型转换。
type columnMeta struct {
Name string `json:"name"`
Type string `json:"type"` // udt_name,如 int8 / varchar / timestamptz / bool / jsonb
IsNullable bool `json:"is_nullable"`
IsIdentity bool `json:"is_identity"`
Default string `json:"default,omitempty"`
}
// tableDump 是单张表的导出结构。
type tableDump struct {
Name string `json:"name"`
Create string `json:"create"` // SHOW CREATE TABLE 得到的完整 DDL
Columns []string `json:"columns"` // SELECT * 得到的列顺序,导入时按此生成 INSERT
Columns []columnMeta `json:"columns"`
Pk []string `json:"pk"`
Rows [][]any `json:"rows"` // 每行为一组值;nil 表示 NULL,string 表示值
}
@ -44,6 +55,30 @@ type dumpFile struct {
Tables []tableDump `json:"tables"`
}
// verbose 控制是否打印每条执行的 SQL(用于排查)。由 dump/import 子命令的 -v 开关设置。
var verbose bool
// vlog 在开启 verbose 时打印调试信息。
func vlog(format string, args ...any) {
if verbose {
log.Printf("[sql] "+format, args...)
}
}
// pgDetail 提取 PostgreSQL 报错的位置/消息,便于定位语法错误。
func pgDetail(err error) string {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return fmt.Sprintf(" [position=%d message=%q where=%q]", pgErr.Position, pgErr.Message, pgErr.Where)
}
return ""
}
// quoteLit 安全包裹 SQL 字符串字面量(转义单引号),用于内联可信的内部标识符/表名。
func quoteLit(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func main() {
if len(os.Args) < 2 {
usage()
@ -61,15 +96,15 @@ func main() {
}
func usage() {
fmt.Println(`dbtool - 后台数据库一键迁移脚本
fmt.Println(`dbtool - 后台数据库一键迁移脚本(PostgreSQL)
用法:
dbtool dump [-config <yml>] [-out <file>] 导出全库 schema+data 到 JSON
dbtool import [-config <yml>] [-in <file>] [-data-only] 从 JSON 回灌(DROP+CREATE+INSERT)
dbtool import [-config <yml>] [-in <file>] [-data-only] 从 JSON 回灌(默认 DROP+CREATE+INSERT)
说明:
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
import 默认连结构带数据全部重建;-data-only 仅导数据(目标库表结构须已存在)。`)
import 默认连结构带数据全部重建;-data-only 仅导数据(表结构须已由后端 AutoMigrate 创建)。`)
}
// openDB 按 config 加载 DSN 并探活。
@ -78,7 +113,7 @@ func openDB(cfgPath string) (*sql.DB, *config.DatabaseConfig, error) {
if err != nil {
return nil, nil, fmt.Errorf("加载配置失败: %w", err)
}
db, err := sql.Open("mysql", cfg.Database.DSN())
db, err := sql.Open("pgx", cfg.Database.DSN())
if err != nil {
return nil, nil, fmt.Errorf("打开数据库连接失败: %w", err)
}
@ -94,7 +129,9 @@ func runDump(args []string) {
fs := flag.NewFlagSet("dump", flag.ExitOnError)
out := fs.String("out", "db_dump.json", "导出文件路径")
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
v := fs.Bool("v", false, "打印每条执行的 SQL,便于排查")
_ = fs.Parse(args)
verbose = *v
db, dcfg, err := openDB(*cfgPath)
if err != nil {
@ -138,7 +175,7 @@ func runImport(args []string) {
fs := flag.NewFlagSet("import", flag.ExitOnError)
in := fs.String("in", "db_dump.json", "导入文件路径")
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
dataOnly := fs.Bool("data-only", false, "仅导数据(表结构须已存在)")
dataOnly := fs.Bool("data-only", false, "仅导数据(表结构须已由 AutoMigrate 创建)")
_ = fs.Parse(args)
raw, err := os.ReadFile(*in)
@ -156,21 +193,30 @@ func runImport(args []string) {
}
defer db.Close()
// 关掉外键检查,避免建表/插数据顺序受 FK 约束(全量重建无需保序)。
if _, err := db.Exec("SET FOREIGN_KEY_CHECKS=0"); err != nil {
log.Fatalf("✗ 关闭外键检查失败: %v", err)
// 关闭外键 / 触发器,避免插入顺序受约束(整库重建无需保序)。
if _, err := db.Exec("SET session_replication_role = 'replica'"); err != nil {
log.Fatalf("✗ 关闭约束检查失败: %v", err)
}
defer db.Exec("SET FOREIGN_KEY_CHECKS=1")
defer db.Exec("SET session_replication_role = 'origin'")
for _, t := range df.Tables {
if !*dataOnly {
log.Printf("→ 重建表 %s ...", t.Name)
if _, err := db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS `%s`", t.Name)); err != nil {
if _, err := db.Exec(fmt.Sprintf(`DROP TABLE IF EXISTS "%s" CASCADE`, t.Name)); err != nil {
log.Fatalf("✗ 删表 %s 失败: %v", t.Name, err)
}
if _, err := db.Exec(t.Create); err != nil {
ddl, err := buildCreate(t)
if err != nil {
log.Fatalf("✗ 生成建表语句失败(%s): %v", t.Name, err)
}
if _, err := db.Exec(ddl); err != nil {
log.Fatalf("✗ 建表 %s 失败: %v", t.Name, err)
}
} else {
// 仅导数据:先清空目标表(约束已由 replica 角色关闭),再插入。
if _, err := db.Exec(fmt.Sprintf(`DELETE FROM "%s"`, t.Name)); err != nil {
log.Fatalf("✗ 清空表 %s 失败: %v", t.Name, err)
}
}
if err := importRows(db, t); err != nil {
log.Fatalf("✗ 导数据到 %s 失败: %v", t.Name, err)
@ -180,9 +226,12 @@ func runImport(args []string) {
log.Printf("✓ 导入完成(%d 张表)", len(df.Tables))
}
// listTables 返回当前库所有表名。
// listTables 返回 public 模式下所有基表名。
func listTables(db *sql.DB) ([]string, error) {
rows, err := db.Query("SHOW TABLES")
rows, err := db.Query(`
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name`)
if err != nil {
return nil, err
}
@ -198,27 +247,27 @@ func listTables(db *sql.DB) ([]string, error) {
return out, rows.Err()
}
// dumpTable 导出单张表的 DDL + 数据。
// dumpTable 导出单张表的列元信息 + 数据。
func dumpTable(db *sql.DB, name string) (tableDump, error) {
// 1) DDL
var dummy, ddl string
if err := db.QueryRow(fmt.Sprintf("SHOW CREATE TABLE `%s`", name)).Scan(&dummy, &ddl); err != nil {
cols, pk, err := dumpColumns(db, name)
if err != nil {
return tableDump{}, err
}
// 2) 数据
rows, err := db.Query(fmt.Sprintf("SELECT * FROM `%s`", name))
dataQuery := fmt.Sprintf(`SELECT * FROM "%s"`, name)
vlog("dump data: %s", dataQuery)
rows, err := db.Query(dataQuery)
if err != nil {
return tableDump{}, err
return tableDump{}, fmt.Errorf("读取数据失败(表 %s): %w\nSQL: %s", name, err, dataQuery)
}
defer rows.Close()
cols, err := rows.Columns()
colNames, err := rows.Columns()
if err != nil {
return tableDump{}, err
}
n := len(cols)
td := tableDump{Name: name, Create: ddl, Columns: cols, Rows: make([][]any, 0)}
n := len(colNames)
td := tableDump{Name: name, Columns: cols, Pk: pk, Rows: make([][]any, 0)}
scanPtrs := make([]any, n)
raw := make([]sql.RawBytes, n)
@ -234,7 +283,7 @@ func dumpTable(db *sql.DB, name string) (tableDump, error) {
if raw[i] == nil {
row[i] = nil // NULL
} else {
row[i] = string(raw[i]) // 全部按字符串搬运,导入时由驱动按列类型适配
row[i] = string(raw[i]) // 全部按字符串搬运,导入时按列类型转换
}
}
td.Rows = append(td.Rows, row)
@ -242,17 +291,192 @@ func dumpTable(db *sql.DB, name string) (tableDump, error) {
return td, rows.Err()
}
// importRows 把一张表的数据批量 INSERT 进目标库(单表一个事务,每批多行,减少网络往返)。
// dumpColumns 读取列元信息与主键。
func dumpColumns(db *sql.DB, table string) ([]columnMeta, []string, error) {
colQuery := strings.Replace(`
SELECT c.column_name,
c.udt_name,
(c.is_nullable = 'YES'),
COALESCE(c.column_default, ''),
COALESCE(c.column_default LIKE 'nextval(%', false)
FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.table_name = $1
ORDER BY c.ordinal_position`, "$1", quoteLit(table), 1)
vlog("dump columns: table=%q query=%s", table, colQuery)
rows, err := db.Query(colQuery)
if err != nil {
return nil, nil, fmt.Errorf("读取列元信息失败(表 %s): %w%s\nSQL: %s", table, err, pgDetail(err), strings.TrimSpace(colQuery))
}
defer rows.Close()
var cols []columnMeta
for rows.Next() {
var name, udt, def string
var nullable, isIdent bool
if err := rows.Scan(&name, &udt, &nullable, &def, &isIdent); err != nil {
return nil, nil, err
}
cols = append(cols, columnMeta{
Name: name, Type: udt, IsNullable: nullable, IsIdentity: isIdent, Default: def,
})
}
if err := rows.Err(); err != nil {
return nil, nil, err
}
pkQuery := strings.Replace(`
SELECT kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema
WHERE tc.table_schema = 'public' AND tc.table_name = $1 AND tc.constraint_type = 'PRIMARY KEY'
ORDER BY kcu.ordinal_position`, "$1", quoteLit(table), 1)
vlog("dump pk: %s", strings.TrimSpace(pkQuery))
pkRows, err := db.Query(pkQuery)
if err != nil {
return nil, nil, fmt.Errorf("读取主键失败(表 %s): %w\nSQL: %s", table, err, strings.TrimSpace(pkQuery))
}
defer pkRows.Close()
var pk []string
for pkRows.Next() {
var c string
if err := pkRows.Scan(&c); err != nil {
return nil, nil, err
}
pk = append(pk, c)
}
return cols, pk, pkRows.Err()
}
// pgType 将 udt_name 映射为建表用的列类型。
func pgType(udt string) string {
switch udt {
case "int2":
return "smallint"
case "int4":
return "integer"
case "int8":
return "bigint"
case "numeric":
return "numeric"
case "float4":
return "real"
case "float8":
return "double precision"
case "bool":
return "boolean"
case "timestamp":
return "timestamp"
case "timestamptz":
return "timestamptz"
case "date":
return "date"
case "time":
return "time"
case "text":
return "text"
case "json":
return "json"
case "jsonb":
return "jsonb"
case "uuid":
return "uuid"
case "bytea":
return "bytea"
default:
return udt // varchar / char / 未知类型原样返回
}
}
// buildCreate 由列元信息重建 CREATE TABLE 语句。
func buildCreate(t tableDump) (string, error) {
var b strings.Builder
b.WriteString(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS "%s" (`, t.Name))
first := true
for _, c := range t.Columns {
if !first {
b.WriteString(",")
}
first = false
b.WriteString(fmt.Sprintf("\n \"%s\" %s", c.Name, pgType(c.Type)))
if c.IsIdentity {
b.WriteString(" GENERATED BY DEFAULT AS IDENTITY")
} else if c.Default != "" {
b.WriteString(" DEFAULT " + c.Default)
}
if !c.IsNullable && !c.IsIdentity {
b.WriteString(" NOT NULL")
}
}
if len(t.Pk) > 0 {
b.WriteString(",\n PRIMARY KEY (" + strings.Join(quoteAll(t.Pk), ",") + ")")
}
b.WriteString("\n);")
return b.String(), nil
}
func quoteAll(cols []string) []string {
out := make([]string, len(cols))
for i, c := range cols {
out[i] = `"` + c + `"`
}
return out
}
// castFor 返回列类型对应的 pg 类型转换后缀(用于导入时把字符串值转为正确类型)。
func castFor(udt string) string {
switch udt {
case "int2", "int4", "int8":
return "bigint"
case "numeric":
return "numeric"
case "float4", "float8":
return "double precision"
case "bool":
return "boolean"
case "timestamp":
return "timestamp"
case "timestamptz":
return "timestamptz"
case "date":
return "date"
case "time":
return "time"
case "json", "jsonb":
return "jsonb"
case "uuid":
return "uuid"
default:
return "" // text / varchar / char 等字符串类型无需转换
}
}
// importRows 把一张表的数据批量 INSERT 进目标库(单表一个事务,每批多行)。
func importRows(db *sql.DB, t tableDump) error {
if len(t.Rows) == 0 {
return nil
}
hasIdentity := false
for _, c := range t.Columns {
if c.IsIdentity {
hasIdentity = true
break
}
}
tx, err := db.Begin()
if err != nil {
return err
}
colList := "`" + strings.Join(t.Columns, "`,`") + "`"
ph := "(" + strings.TrimSuffix(strings.Repeat("?,", len(t.Columns)), ",") + ")"
defer func() { _ = tx.Rollback() }()
colList := make([]string, len(t.Columns))
casts := make([]string, len(t.Columns))
for i, c := range t.Columns {
colList[i] = `"` + c.Name + `"`
casts[i] = castFor(c.Type)
}
const batchSize = 200
for start := 0; start < len(t.Rows); start += batchSize {
end := start + batchSize
@ -260,20 +484,52 @@ func importRows(db *sql.DB, t tableDump) error {
end = len(t.Rows)
}
chunk := t.Rows[start:end]
var sb strings.Builder
sb.WriteString(fmt.Sprintf("INSERT INTO `%s` (%s) VALUES ", t.Name, colList))
ov := ""
if hasIdentity {
ov = " OVERRIDING SYSTEM VALUE"
}
sb.WriteString(fmt.Sprintf(`INSERT INTO "%s" (%s)%s VALUES `, t.Name, strings.Join(colList, ","), ov))
args := make([]any, 0, len(chunk)*len(t.Columns))
for i, r := range chunk {
if i > 0 {
param := 1
for ri, row := range chunk {
if ri > 0 {
sb.WriteString(",")
}
sb.WriteString(ph)
args = append(args, r...)
sb.WriteString("(")
for ci, val := range row {
if ci > 0 {
sb.WriteString(",")
}
if val == nil {
sb.WriteString("NULL")
} else {
if casts[ci] != "" {
sb.WriteString(fmt.Sprintf("$%d::%s", param, casts[ci]))
} else {
sb.WriteString(fmt.Sprintf("$%d", param))
}
args = append(args, val)
param++
}
}
sb.WriteString(")")
}
if _, err := tx.Exec(sb.String(), args...); err != nil {
_ = tx.Rollback()
return err
}
}
// 重置 identity 序列,避免后续自增插入与已导入的最大 ID 冲突。
for _, c := range t.Columns {
if c.IsIdentity {
seq := fmt.Sprintf("pg_get_serial_sequence('%s','%s')", t.Name, c.Name)
if _, err := tx.Exec(fmt.Sprintf(
`SELECT setval(%s, COALESCE((SELECT MAX("%s") FROM "%s"), 1))`, seq, c.Name, t.Name)); err != nil {
log.Printf("! 重置序列 %s.%s 失败: %v", t.Name, c.Name, err)
}
}
}
return tx.Commit()
}

View File

@ -67,7 +67,11 @@ func main() {
log.Printf("! 关闭数据库连接失败: %v", err)
}
}()
log.Println("✓ MySQL 已连接:", cfg.Database.Addr())
// 2.5 幂等自动迁移表结构(替代原手写 MySQL 迁移脚本)
if err := database.AutoMigrate(db); err != nil {
log.Fatalf("✗ 自动迁移失败: %v", err)
}
log.Println("✓ PostgreSQL 已连接:", cfg.Database.Addr())
// 3. 确保上传目录存在(静态文件服务的根目录)
uploadDir, _ := filepath.Abs(cfg.Upload.Dir)

View File

@ -20,19 +20,18 @@ server:
database:
host: 127.0.0.1 # env: DB_HOST
port: 3306 # env: DB_PORT
user: root # env: DB_USER
password: root # env: DB_PASSWORD(兼容 DB_PASS)
# 本地开发库名为 db_dev;docker-compose 部署时用环境变量覆盖为 fashionadmin
name: db_dev # env: DB_NAME
charset: utf8mb4
port: 5432 # env: DB_PORT
user: fashion # env: DB_USER
password: fashion_dev_2026 # env: DB_PASSWORD(兼容 DB_PASS)
# 本地开发库名 fashion;对应 scripts/pgvector 的 Docker PostgreSQL 容器
name: fashion # env: DB_NAME
# GORM 日志级别:silent | error | warn | info
log_level: warn
# 连接池
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 3600 # 秒
conn_max_idle_time: 60 # 秒,空闲连接回收时间;须 < MySQL wait_timeout,防止池子持有被回收的死连接导致 invalid connection
conn_max_idle_time: 60 # 秒,空闲连接回收时间,防止池子持有被服务端回收的死连接导致 invalid connection
jwt:
# 生产环境务必用环境变量覆盖为随机长字符串
@ -80,7 +79,7 @@ client_sign:
# 爬虫入库管线:暴露 :8092 /admin/internal/ingest 接收爬虫 HMAC 签名上报。
# 设计:服务端到服务端(非前端 JS),密钥绝不下发前端、只存爬虫配置与 INGEST_SECRET,安全高。
# - secret 必须与爬虫端 config.yml 的 ingest.secret 完全一致;env INGEST_SECRET 注入随机长串(生产)。
# - 依赖迁移 db/migrations/002_ingest.sql(建 ingest_jobs / ingest_nonces)。
# - 建表由 GORM AutoMigrate 自动完成(ingest_jobs / ingest_nonces 等),无需手工 SQL 迁移。
# - 启用后 worker 随主进程拉起(cmd/server 内 goroutine),异步下载图、补 season_code、写正式表。
ingest:
secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致)

17
go.mod
View File

@ -3,20 +3,21 @@ module fashionapi
go 1.26.5
require (
github.com/aws/aws-sdk-go-v2 v1.47.0
github.com/aws/aws-sdk-go-v2/config v1.33.4
github.com/aws/aws-sdk-go-v2/credentials v1.20.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.0
github.com/gin-gonic/gin v1.12.0
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.11.0
golang.org/x/crypto v0.54.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.3
gorm.io/gorm v1.31.2
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.47.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/config v1.33.4 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect
@ -25,7 +26,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect
@ -40,8 +40,10 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@ -58,6 +60,7 @@ require (
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect

42
go.sum
View File

@ -1,5 +1,3 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ=
github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
@ -45,6 +43,7 @@ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
@ -52,21 +51,31 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@ -79,6 +88,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -86,6 +97,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
@ -96,41 +108,43 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@ -59,32 +59,27 @@ type ServerConfig struct {
HashIDSecret string `yaml:"hashid_secret"`
}
// DatabaseConfig MySQL 连接与连接池配置。
// DatabaseConfig PostgreSQL 连接与连接池配置。
type DatabaseConfig struct {
Host string `yaml:"host"`
Port string `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
Name string `yaml:"name"`
Charset string `yaml:"charset"`
LogLevel string `yaml:"log_level"`
MaxIdleConns int `yaml:"max_idle_conns"`
MaxOpenConns int `yaml:"max_open_conns"`
ConnMaxLifetime int `yaml:"conn_max_lifetime"`
// ConnMaxIdleTime 空闲连接回收时间(秒)。必须 < MySQL wait_timeout,否则 MySQL 回收空闲
// 连接后,连接池仍持有死连接,下一次查询会报 "invalid connection" / "bad connection"。
// ConnMaxIdleTime 空闲连接回收时间(秒)。避免长时间持有被服务端回收的死连接,
// 下一次查询报 "invalid connection" / "bad connection"。
ConnMaxIdleTime int `yaml:"conn_max_idle_time"`
}
// DSN 组装 MySQL 连接串。
// DSN 组装 PostgreSQL 连接串(pgx 驱动,本地开发禁用 SSL)。
func (d DatabaseConfig) DSN() string {
charset := d.Charset
if charset == "" {
charset = "utf8mb4"
}
return fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=True&loc=Local&timeout=10s",
d.User, d.Password, d.Host, d.Port, d.Name, charset,
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
d.User, d.Password, d.Host, d.Port, d.Name,
)
}
@ -186,11 +181,10 @@ func defaultConfig() *Config {
},
Database: DatabaseConfig{
Host: "127.0.0.1",
Port: "3306",
User: "root",
Password: "root",
Name: "db_dev",
Charset: "utf8mb4",
Port: "5432",
User: "fashion",
Password: "fashion_dev_2026",
Name: "fashion",
LogLevel: "warn",
MaxIdleConns: 10,
MaxOpenConns: 100,

View File

@ -1,72 +0,0 @@
// Package database 负责数据库连接的建立与生命周期管理。
//
// 这里刻意不提供包级全局 DB 变量:*gorm.DB 由 main 装配后显式注入各 repository,
// 依赖关系清晰可见,也让 repository 可以在测试中替换为独立的数据库实例。
package database
import (
"fmt"
"time"
"fashionapi/internal/config"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// New 建立 MySQL 连接并完成连接池设置。
func New(cfg config.DatabaseConfig) (*gorm.DB, error) {
db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(parseLogLevel(cfg.LogLevel)),
// 关闭默认事务可显著提升只读接口的吞吐;写操作按需显式开启事务
SkipDefaultTransaction: true,
})
if err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("获取底层连接池失败: %w", err)
}
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
// 空闲回收:定期关闭空闲连接,避免持有被 MySQL wait_timeout 回收的死连接。
// 仅当配置 > 0 时启用(0 = 沿用旧行为,不回收)。
if cfg.ConnMaxIdleTime > 0 {
sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second)
}
// 立刻探活,避免"启动成功但首个请求才报错"
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("数据库 Ping 失败: %w", err)
}
return db, nil
}
// Close 关闭数据库连接池。
func Close(db *gorm.DB) error {
if db == nil {
return nil
}
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
func parseLogLevel(s string) logger.LogLevel {
switch s {
case "silent":
return logger.Silent
case "error":
return logger.Error
case "info":
return logger.Info
default:
return logger.Warn
}
}

View File

@ -0,0 +1,157 @@
// Package database 负责数据库连接的建立与生命周期管理。
//
// 这里刻意不提供包级全局 DB 变量:*gorm.DB 由 main 装配后显式注入各 repository,
// 依赖关系清晰可见,也让 repository 可以在测试中替换为独立的数据库实例。
//
// 数据库已迁移至 PostgreSQL(见 scripts/pgvector 的本地 Docker 环境)。
// 表结构由 GORM AutoMigrate 在启动时幂等创建,不再依赖手写的 MySQL 迁移脚本。
package database
import (
"fmt"
"time"
"fashionapi/internal/config"
"fashionapi/internal/model"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// New 建立 PostgreSQL 连接并完成连接池设置。
func New(cfg config.DatabaseConfig) (*gorm.DB, error) {
db, err := gorm.Open(postgres.Open(cfg.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(parseLogLevel(cfg.LogLevel)),
// 关闭默认事务可显著提升只读接口的吞吐;写操作按需显式开启事务
SkipDefaultTransaction: true,
})
if err != nil {
return nil, fmt.Errorf("连接数据库失败: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("获取底层连接池失败: %w", err)
}
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
// 空闲回收:定期关闭空闲连接,避免持有被服务端回收的死连接。
// 仅当配置 > 0 时启用(0 = 沿用旧行为,不回收)。
if cfg.ConnMaxIdleTime > 0 {
sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second)
}
// 立刻探活,避免"启动成功但首个请求才报错"
if err := sqlDB.Ping(); err != nil {
return nil, fmt.Errorf("数据库 Ping 失败: %w", err)
}
return db, nil
}
// AutoMigrate 幂等创建 / 更新全部表结构。
//
// 表结构由 GORM 模型定义托管(替代原 scripts/sql、db/migrations 下的手写 MySQL 脚本)。
// 仅在缺少列/索引时增量变更,已存在的表不会被重建;对存量大表(如 brand_runway_images)
// 的加列操作可能短暂加锁,属一次性开销。
func AutoMigrate(db *gorm.DB) error {
// 确保 pgvector 扩展存在(dHash 近重复 HNSW 索引与语义 embedding 向量列均依赖它)。
if err := db.Exec("CREATE EXTENSION IF NOT EXISTS vector").Error; err != nil {
return fmt.Errorf("create extension vector: %w", err)
}
return db.AutoMigrate(
&model.Brand{},
&model.User{},
&model.RefreshToken{},
&model.IngestNonce{},
&model.IngestJob{},
&model.StreetSnap{},
&model.StreetSnapImage{},
&model.StreetSnapDraft{},
&model.StreetSnapDraftImage{},
&model.Favorite{},
&model.History{},
&model.BrandRunway{},
&model.BrandRunwayImage{},
&model.BrandRunwayDraft{},
&model.BrandRunwayDraftImage{},
)
}
// ensureDedupSchema 在 GORM AutoMigrate 之外,补充去重所需的索引与语义 embedding 表。
// 全部幂等(IF NOT EXISTS),可随服务启动重复执行。
//
// - 各 image 表的 content_sha1 部分唯一索引(排除迁移前遗留的空串行,避免唯一约束冲突);
// - phash 的 HNSW 索引(bit_hamming_ops,汉明距离近邻检索,替代原先的全表扫描);
// - image_embeddings 表 + cosine HNSW 索引:语义 embedding(CLIP/DINOv2)落库位,
// 待后续接入推理填充,当前不写入。
func ensureDedupSchema(db *gorm.DB) error {
tables := []struct {
name string
idx string
}{
{"brand_runway_images", "uq_br_imgs_sha1"},
{"brand_runway_draft_images", "uq_br_draft_imgs_sha1"},
{"street_snap_images", "uq_ss_imgs_sha1"},
{"street_snap_draft_images", "uq_ss_draft_imgs_sha1"},
}
for _, t := range tables {
// 精确去重:content_sha1 唯一(排除空串遗留行)。
if err := db.Exec(fmt.Sprintf(
"CREATE UNIQUE INDEX IF NOT EXISTS %s ON %s (content_sha1) WHERE content_sha1 <> ''",
t.idx, t.name,
)).Error; err != nil {
return fmt.Errorf("create unique index %s: %w", t.idx, err)
}
// 近重复检索:dHash 向量(vector(64) 二进制)汉明 HNSW 索引。
if err := db.Exec(fmt.Sprintf(
"CREATE INDEX IF NOT EXISTS %s_phash_hnsw ON %s USING hnsw (phash bit_hamming_ops)",
t.idx, t.name,
)).Error; err != nil {
return fmt.Errorf("create hnsw index %s: %w", t.idx, err)
}
}
// 语义 embedding 表(CLIP/DINOv2 等 512 维向量):待后续推理接入填充;当前不写入。
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS image_embeddings (
id BIGSERIAL PRIMARY KEY,
image_id INTEGER NOT NULL,
kind SMALLINT NOT NULL,
embedding vector(512),
created_at INTEGER NOT NULL DEFAULT 0
)`).Error; err != nil {
return fmt.Errorf("create image_embeddings: %w", err)
}
if err := db.Exec(
"CREATE INDEX IF NOT EXISTS image_embeddings_vec_idx ON image_embeddings USING hnsw (embedding vector_cosine_ops)",
).Error; err != nil {
return fmt.Errorf("create embeddings hnsw: %w", err)
}
return nil
}
// Close 关闭数据库连接池。
func Close(db *gorm.DB) error {
if db == nil {
return nil
}
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
func parseLogLevel(s string) logger.LogLevel {
switch s {
case "silent":
return logger.Silent
case "error":
return logger.Error
case "info":
return logger.Info
default:
return logger.Warn
}
}

View File

@ -1,5 +1,7 @@
package model
import "database/sql"
// BrandRunwayDraft 走秀草稿(爬虫入库待审核)。
//
// 爬虫 worker 处理完 ingest_jobs 后不直写正式表,而是写入本表并置为 pending;
@ -42,9 +44,12 @@ type BrandRunwayDraftImage struct {
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
Phash uint64 `gorm:"column:phash" json:"phash"` // 感知哈希 64-bit;0=未计算
IsDuplicate uint8 `gorm:"column:is_duplicate" json:"is_duplicate"` // 近似重复标记:0=否 1=是(命中留痕不删)
DupOf string `gorm:"column:dup_of" json:"dup_of"` // 近似重复指向的图 uid(hashid),''=非重复
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
ContentSha1 string `gorm:"column:content_sha1;type:varchar(64);not null;default:''" json:"content_sha1"`
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
}
// TableName 指定草稿图片表名。

View File

@ -1,5 +1,7 @@
package model
import "database/sql"
// BrandRunwayImage 走秀图片。
type BrandRunwayImage struct {
ID uint32 `gorm:"primaryKey;column:id" json:"id"`
@ -13,9 +15,15 @@ type BrandRunwayImage struct {
SortOrder uint32 `gorm:"column:sort_order" json:"sort_order"` // 拖拽排序用,由迁移脚本新增
LookIndex uint32 `gorm:"column:look_index" json:"look_index"` // 细节图归属的主图序号(主图=该 look;细节图=所属主图的 look)
IsDetail uint8 `gorm:"column:is_detail" json:"is_detail"` // 0=主图/look 图(默认展示) 1=细节图
Phash uint64 `gorm:"column:phash" json:"phash"` // 感知哈希 64-bit;0=未计算(存量)
IsDuplicate uint8 `gorm:"column:is_duplicate" json:"is_duplicate"` // 近似重复标记:0=否 1=是(命中留痕不删)
DupOf string `gorm:"column:dup_of" json:"dup_of"` // 近似重复指向的图 uid(hashid),''=非重复
// 去重字段(精确 + 近重复 + 语义 embedding 留痕):
// content_sha1 图片内容 sha1,与存储 key 同源,空串为迁移前遗留行;
// phash 为 dHash 的 pgvector 二进制向量(vector(64)),NULL 表示无法解码(如 webp),不参与检索;
// is_duplicate / dup_of 仅在命中近重复时标记,供人工审核留痕,不阻断入库。
ContentSha1 string `gorm:"column:content_sha1;type:varchar(64);not null;default:''" json:"content_sha1"`
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
}
// TableName 指定表名。

View File

@ -1,5 +1,7 @@
package model
import "database/sql"
// StreetSnap 街拍主表(对应 db_dev.street_snap)。
//
// 字段对齐 db_dev 约定:INT UNSIGNED 主键、int unsigned 时间戳、is_deleted 软删、varchar(255)。
@ -31,9 +33,12 @@ type StreetSnapImage struct {
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
Phash uint64 `gorm:"column:phash" json:"phash"` // 感知哈希 64-bit;0=未计算(存量)
IsDuplicate uint8 `gorm:"column:is_duplicate" json:"is_duplicate"` // 近似重复标记:0=否 1=是(命中留痕不删)
DupOf string `gorm:"column:dup_of" json:"dup_of"` // 近似重复指向的图 uid(hashid),''=非重复
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
ContentSha1 string `gorm:"column:content_sha1;type:varchar(64);not null;default:''" json:"content_sha1"`
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
}
// TableName 指定图片明细表名。

View File

@ -1,5 +1,7 @@
package model
import "database/sql"
// StreetSnapDraft 街拍草稿(爬虫入库待审核)。
//
// 与 brand_runway_draft 同构的审核流程:爬虫 worker 处理完 ingest_jobs 后写入本表并置
@ -36,9 +38,12 @@ type StreetSnapDraftImage struct {
IsDeleted uint8 `gorm:"column:is_deleted" json:"is_deleted"`
CreatedAt uint32 `gorm:"column:created_at" json:"created_at"`
UpdatedAt uint32 `gorm:"column:updated_at" json:"updated_at"`
Phash uint64 `gorm:"column:phash" json:"phash"` // 感知哈希 64-bit;0=未计算
IsDuplicate uint8 `gorm:"column:is_duplicate" json:"is_duplicate"` // 近似重复标记:0=否 1=是(命中留痕不删)
DupOf string `gorm:"column:dup_of" json:"dup_of"` // 近似重复指向的图 uid(hashid),''=非重复
// 去重字段:见 BrandRunwayImage 注释。phash 为 vector(64) 二进制向量(NULL=无法解码)。
ContentSha1 string `gorm:"column:content_sha1;type:varchar(64);not null;default:''" json:"content_sha1"`
Phash sql.NullString `gorm:"column:phash;type:vector(64)" json:"phash"`
IsDuplicate uint8 `gorm:"column:is_duplicate;not null;default:0" json:"is_duplicate"`
DupOf string `gorm:"column:dup_of;type:varchar(32);not null;default:''" json:"dup_of"`
}
// TableName 指定草稿图片表名。

126
internal/pkg/phash/phash.go Normal file
View File

@ -0,0 +1,126 @@
// Package phash 计算图片的感知哈希(dHash,Difference Hash)。
//
// dHash 是 64-bit 轻量指纹,用于判断「同一张图被转码 / 加水印 / 改尺寸」这类近重复。
// 去重检索不在这里做——交给 PostgreSQL + pgvector 的 HNSW 索引(见 database 包的
// ensureDedupSchema),本包只负责产出指纹,复杂度极低、零 CGO、零 ML 依赖。
//
// 与之前被移除的全表扫描实现不同:现在指纹只写库、检索交给索引,O(1) 桶内比对即可,
// 图片量级再大也扛得住。
package phash
import (
"bytes"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"math"
"strings"
)
// Size 是哈希位数(9x8 灰度 → 8 行 × 8 差 = 64 bit)。
const Size = 64
// DefaultThreshold 汉明距离阈值:≤ 该值判为近重复。可按真实样本微调。
const DefaultThreshold = 10
// Of 计算 data(jpeg/png/gif 字节流)的 dHash 指纹。
// 解码失败返回 0,调用方据此把 phash 置空(NULL,不参与近邻检索),不阻断入库。
func Of(data []byte) uint64 {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return 0
}
return dhash(img)
}
const (
dw = 9 // 灰度矩阵宽度(列数)
dh = 8 // 灰度矩阵高度(行数)
)
// dhash 把图像缩到 9x8 灰度,逐行比较相邻像素亮度,得到 64-bit 指纹。
func dhash(img image.Image) uint64 {
b := img.Bounds()
scaleX := float64(b.Dx()) / float64(dw)
scaleY := float64(b.Dy()) / float64(dh)
gray := make([][]float64, dh)
for y := 0; y < dh; y++ {
gray[y] = make([]float64, dw)
y0 := int(float64(y) * scaleY)
y1 := int(math.Ceil(float64(y+1) * scaleY))
if y1 <= y0 {
y1 = y0 + 1
}
for x := 0; x < dw; x++ {
x0 := int(float64(x) * scaleX)
x1 := int(math.Ceil(float64(x+1) * scaleX))
if x1 <= x0 {
x1 = x0 + 1
}
var sum float64
var n int
for sy := y0; sy < y1 && sy < b.Dy(); sy++ {
for sx := x0; sx < x1 && sx < b.Dx(); sx++ {
r, g, bl, _ := img.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
// 感知亮度(ITU-R BT.601)
lum := 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bl>>8)
sum += lum
n++
}
}
if n > 0 {
gray[y][x] = sum / float64(n)
}
}
}
var hash uint64
i := 0
for y := 0; y < dh; y++ {
for x := 0; x < dw-1; x++ {
if gray[y][x] > gray[y][x+1] {
hash |= 1 << uint(i)
}
i++
}
}
return hash
}
// ToVectorBits 把 64-bit 指纹转成 pgvector 的二进制向量字符串 "[0,1,...,1]"(长度 64),
// 用于写入 vector(64) 列,并以 bit_hamming_ops 建 HNSW 索引做汉明近邻检索。
// h 为 0(解码失败)时返回空串,调用方据此存 NULL、不参与检索。
func ToVectorBits(h uint64) string {
if h == 0 {
return ""
}
var sb strings.Builder
sb.Grow(Size + 1)
sb.WriteByte('[')
for i := 0; i < Size; i++ {
if i > 0 {
sb.WriteByte(',')
}
if (h>>uint(i))&1 == 1 {
sb.WriteByte('1')
} else {
sb.WriteByte('0')
}
}
sb.WriteByte(']')
return sb.String()
}
// Hamming 返回两个指纹的汉明距离(不同 bit 数)。仅在 Go 侧需要手工比对时使用;
// 检索场景直接交给 pgvector 的 <~> 算子。
func Hamming(a, b uint64) int {
c := 0
x := a ^ b
for x != 0 {
x &= x - 1
c++
}
return c
}

View File

@ -92,7 +92,7 @@ func (r *brandRepository) brandFilterScope(q dto.BrandQuery, restrictIDs []uint3
case q.Letter >= "A" && q.Letter <= "Z" && len(q.Letter) == 1:
db = db.Where("UPPER(LEFT(name_en,1)) = ?", q.Letter)
default:
db = db.Where("name_en REGEXP ?", latinPrefixRegexp)
db = db.Where("name_en ~ ?", latinPrefixRegexp)
}
// 字母索引始终只返回"拥有走秀档案"的品牌(库中有 2 万+ 无档案品牌,

View File

@ -44,6 +44,10 @@ type IngestRepository interface {
CreateStreetSnapDraft(ctx context.Context, d *model.StreetSnapDraft) (uint32, error)
// CreateStreetSnapDraftImages 批量插入街拍草稿图片行。
CreateStreetSnapDraftImages(ctx context.Context, imgs []model.StreetSnapDraftImage) error
// ImageExistsBySha1 在给定图片表中是否已存在相同内容哈希(精确重复);用于入库前跳过该行。
ImageExistsBySha1(ctx context.Context, tables []string, sha1 string) (bool, error)
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离检索近重复,返回命中行 id(仅取最近一条)。
FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (dupID uint32, found bool, err error)
// ListJobs 按 id 倒序分页列出入库任务(用于后台监控页)。offset/limit 控制分页。
ListJobs(ctx context.Context, offset, limit int) ([]model.IngestJob, error)
// CountJobs 返回 ingest_jobs 总条数(用于分页计算总页数)。
@ -56,16 +60,6 @@ type IngestRepository interface {
// ScheduleRetry 失败时调用:attempts+1,未达上限则退避后重置 pending,达上限则置 failed。
// 用于临时失败(网络抖动 / 单图下载失败)的自动重试,区别于永久失败(payload 解析错等)直接 MarkFailed。
ScheduleRetry(ctx context.Context, id uint32, errMsg string) error
// ListImagePHashes 返回全部已晋升图片(runway + street)的 (id, phash, kind),
// 跳过 is_deleted 与 phash=0/NULL(存量未计算)。供入库时与新增图做全局汉明比对(近似去重)。
ListImagePHashes(ctx context.Context) ([]ImagePHash, error)
}
// ImagePHash 已晋升图片的感知哈希摘要,供入库时全局近似去重比对。
type ImagePHash struct {
ID uint32 // 图片数字主键
Phash uint64 // 感知哈希(SQL 已过滤 0/NULL)
Kind string // "runway" | "street"(决定 dup_of 的 hashid 类型)
}
type ingestRepository struct {
@ -376,38 +370,45 @@ func (r *ingestRepository) RetryJob(ctx context.Context, id uint32) error {
}).Error
}
// ListImagePHashes 返回全部已晋升图片(runway + street)的感知哈希摘要,供入库时全局近似去重。
// 跳过 is_deleted 与 phash=0/NULL(存量未计算)。结果合并 runway + street 两类,
// 用 Kind 标注类型,调用方据此把 dup_of 编码成对应 hashid 类型。
//
// 注:每次入库任务都会全量拉取一次(图片量当前为千级,可接受);若后续图片量到十万级,
// 可改为按 runway_id/snap_id 分批或加内存缓存 + 定时刷新,避免每 job 一次全表扫描。
func (r *ingestRepository) ListImagePHashes(ctx context.Context) ([]ImagePHash, error) {
type phRow struct {
// ImageExistsBySha1 在给定图片表中是否已存在相同 content_sha1(精确重复)。
func (r *ingestRepository) ImageExistsBySha1(ctx context.Context, tables []string, sha1 string) (bool, error) {
for _, t := range tables {
var c int64
if err := r.db.WithContext(ctx).Table(t).
Where("content_sha1 = ? AND is_deleted = 0", sha1).Count(&c).Error; err != nil {
return false, err
}
if c > 0 {
return true, nil
}
}
return false, nil
}
// FindNearDuplicateImage 在给定图片表中按 dHash 汉明距离(pgvector <~> 算子)检索近重复,
// 取距离 ≤ threshold 的最近一条。phashBits 为 vector(64) 二进制向量串;NULL 的 phash 不参与比较。
func (r *ingestRepository) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) {
for _, t := range tables {
var row struct {
ID uint32 `gorm:"column:id"`
Phash uint64 `gorm:"column:phash"`
Dist int `gorm:"column:dist"`
}
out := make([]ImagePHash, 0, 64)
const whereActive = "is_deleted = 0 AND phash IS NOT NULL AND phash <> 0"
var rw []phRow
if err := r.db.WithContext(ctx).Model(&model.BrandRunwayImage{}).
Select("id, phash").Where(whereActive).Scan(&rw).Error; err != nil {
return nil, err
err := r.db.WithContext(ctx).Table(t).
Select("id, (phash <~> ?::vector) AS dist", phashBits).
Where("(phash <~> ?::vector) <= ?", phashBits, threshold).
Order("dist ASC").
Limit(1).
Scan(&row).Error
if err != nil {
return 0, false, err
}
for _, x := range rw {
out = append(out, ImagePHash{ID: x.ID, Phash: x.Phash, Kind: "runway"})
if row.ID != 0 {
return row.ID, true, nil
}
var sn []phRow
if err := r.db.WithContext(ctx).Model(&model.StreetSnapImage{}).
Select("id, phash").Where(whereActive).Scan(&sn).Error; err != nil {
return nil, err
}
for _, x := range sn {
out = append(out, ImagePHash{ID: x.ID, Phash: x.Phash, Kind: "street"})
}
return out, nil
return 0, false, nil
}
// isDuplicateKey 兜底:gorm 的 ErrDuplicatedKey 在不同驱动下的封装不一定一致,

View File

@ -252,6 +252,7 @@ func (r *reviewRepository) SaveRunwayFromDraft(ctx context.Context, draftID uint
SortOrder: uint32(i + 1),
LookIndex: im.LookIndex,
IsDetail: im.IsDetail,
ContentSha1: im.ContentSha1,
Phash: im.Phash,
IsDuplicate: im.IsDuplicate,
DupOf: im.DupOf,
@ -444,6 +445,7 @@ func (r *reviewRepository) SaveStreetSnapFromDraft(ctx context.Context, draftID
Image: im.Image,
Name: im.Name,
SortOrder: uint32(i + 1),
ContentSha1: im.ContentSha1,
Phash: im.Phash,
IsDuplicate: im.IsDuplicate,
DupOf: im.DupOf,

View File

@ -9,6 +9,7 @@ import (
"strings"
"testing"
"fashionapi/internal/dto"
"fashionapi/internal/pkg/storage"
)
@ -25,12 +26,12 @@ func TestFetchImagesCleansUpOnFailure(t *testing.T) {
return
}
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("fakeimagebytes"))
_, _ = w.Write([]byte("fakeimagebytes" + r.URL.Path))
}))
defer srv.Close()
urls := []string{srv.URL + "/ok1.jpg", srv.URL + "/ok2.jpg", srv.URL + "/bad.jpg"}
_, out, _, keys, failed := s.fetchImages(context.Background(), urls, "runway")
_, out, keys, failed := s.fetchImages(context.Background(), urls, "runway", dto.IngestKindRunway)
if !failed {
t.Fatalf("expected failed=true when one image errors")
}

View File

@ -3,6 +3,7 @@ package service
import (
"context"
"crypto/sha1"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
@ -10,6 +11,7 @@ import (
"log"
"net/http"
"path"
"strconv"
"time"
"fashionapi/internal/dto"
@ -235,36 +237,31 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
// 4) 下载图片并上传到存储(结构化 Looks 优先:主图+细节图分组;否则回退 Images 全部视为主图)。
// 内容哈希(sha1)key 保证重爬不产生孤儿文件:失败回滚删本批 key 即可。
// 同时算出每张图的 pHash(downloadOne 内基于原始字节),供后续全局近似去重标记。
var cover string
var draftImages []model.BrandRunwayDraftImage
var keys []string
var imgFailed bool
var imageCount uint16
var phashList []uint64
if len(p.Looks) > 0 {
cover, draftImages, keys, imgFailed = s.fetchLookImages(ctx, p.Looks, "runway")
imageCount = uint16(len(p.Looks))
phashList = make([]uint64, len(draftImages))
for i, d := range draftImages {
phashList[i] = d.Phash
}
} else {
var imgs []string
var phs []uint64
cover, imgs, phs, keys, imgFailed = s.fetchImages(ctx, p.Images, "runway")
imageCount = uint16(len(imgs))
for i, img := range imgs {
var fimgs []fetchedImage
cover, fimgs, keys, imgFailed = s.fetchImages(ctx, p.Images, "runway", dto.IngestKindRunway)
imageCount = uint16(len(fimgs))
for i, fi := range fimgs {
draftImages = append(draftImages, model.BrandRunwayDraftImage{
Image: img,
Image: fi.url,
Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1),
LookIndex: uint32(i + 1),
IsDetail: 0,
Phash: phs[i],
ContentSha1: fi.sha1,
Phash: sqlNull(fi.phash),
IsDuplicate: fi.isDup,
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
})
}
phashList = phs
}
if imgFailed {
// 单图失败=整任务失败:先回滚本批已上传的图(S4 + 本地兜底),避免孤儿文件永远堆在存储里,
@ -274,12 +271,6 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
return
}
// 4.5) 全局近似去重标记:与「已晋升图片」比对汉明距离,命中则留痕(is_duplicate=1 + dup_of),
// 不丢弃、交后台人工裁决(只拦新增,不碰存量)。比对失败仅告警,不阻断入库。
s.tagDuplicates(ctx, phashList, func(i int, dupOf string) {
draftImages[i].IsDuplicate = 1
draftImages[i].DupOf = dupOf
})
// 5) 写草稿表(status=pending),等待后台审核通过后再晋升正式表
draft := &model.BrandRunwayDraft{
@ -316,31 +307,44 @@ func (s *IngestService) processRunway(ctx context.Context, job model.IngestJob,
// BrandRunwayDraftImage 行(带 look_index / is_detail 分组)。任意一张下载/上传失败即把
// failed 置 true,调用方据此把整条任务判失败并回滚本批已上传的 key,符合「单图失败=整任务失败」策略。
// cover 取首个成功下载的主图;image_count(主图数)由调用方按 len(Looks) 计,不在此返回。
// 每张图入库前做去重:content_sha1 已存在则整行跳过(精确重复);命中 dHash 近重复则仍入库但标记留痕。
func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayLook, prefix string) (string, []model.BrandRunwayDraftImage, []string, bool) {
cover := ""
rows := make([]model.BrandRunwayDraftImage, 0)
keys := make([]string, 0)
seen := make(map[string]bool)
failed := false
order := 0
for li, look := range looks {
lookIdx := li + 1
if look.Main != "" {
order++
url, key, ph, err := s.downloadOne(ctx, look.Main, order, prefix)
url, key, sha1h, ph, err := s.downloadOne(ctx, look.Main, order, prefix)
if err != nil {
failed = true
} else {
keys = append(keys, key)
if seen[sha1h] {
continue
}
seen[sha1h] = true
skip, dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, sha1h, ph)
if skip {
continue
}
order++
if cover == "" {
cover = url
}
keys = append(keys, key)
rows = append(rows, model.BrandRunwayDraftImage{
Image: url,
Name: fmt.Sprintf("Look %d", lookIdx),
SortOrder: uint32(order),
LookIndex: uint32(lookIdx),
IsDetail: 0,
Phash: ph,
ContentSha1: sha1h,
Phash: sqlNull(ph),
IsDuplicate: isDup,
DupOf: strconv.FormatUint(uint64(dupID), 10),
})
}
}
@ -348,20 +352,31 @@ func (s *IngestService) fetchLookImages(ctx context.Context, looks []dto.RunwayL
if d == "" {
continue
}
order++
url, key, ph, err := s.downloadOne(ctx, d, order, prefix)
url, key, sha1h, ph, err := s.downloadOne(ctx, d, order, prefix)
if err != nil {
failed = true
continue
}
keys = append(keys, key)
if seen[sha1h] {
continue
}
seen[sha1h] = true
skip, dupID, isDup := s.dedupImage(ctx, dto.IngestKindRunway, sha1h, ph)
if skip {
continue
}
order++
rows = append(rows, model.BrandRunwayDraftImage{
Image: url,
Name: fmt.Sprintf("Look %d — Detail %d", lookIdx, di+1),
SortOrder: uint32(order),
LookIndex: uint32(lookIdx),
IsDetail: 1,
Phash: ph,
ContentSha1: sha1h,
Phash: sqlNull(ph),
IsDuplicate: isDup,
DupOf: strconv.FormatUint(uint64(dupID), 10),
})
}
}
@ -377,8 +392,8 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
return
}
// 2) 下载图片并上传到存储(七牛优先,失败兜底本地);同时算 pHash 供近似去重。
cover, imgs, phs, keys, imgFailed := s.fetchImages(ctx, p.Images, "street")
// 2) 下载图片并上传到存储(七牛优先,失败兜底本地)。
cover, fimgs, keys, imgFailed := s.fetchImages(ctx, p.Images, "street", dto.IngestKindStreet)
if len(p.Images) > 0 && imgFailed {
// 单图失败=整任务失败:先回滚本批已上传的图(七牛 + 本地兜底),避免孤儿文件永远堆在存储里,
// 然后按指数退避自动重试,达上限才置 failed 等后台手动重试。
@ -394,7 +409,7 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
Year: p.Year,
City: p.City,
Cover: cover,
ImageCount: uint16(len(imgs)),
ImageCount: uint16(len(fimgs)),
Status: model.DraftStatusPending,
}
id, err := s.repo.CreateStreetSnapDraft(ctx, draft)
@ -402,22 +417,20 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
s.failOrRetry(ctx, job.ID, "create street draft: "+err.Error())
return
}
rows := make([]model.StreetSnapDraftImage, 0, len(imgs))
for i, img := range imgs {
rows := make([]model.StreetSnapDraftImage, 0, len(fimgs))
for i, fi := range fimgs {
rows = append(rows, model.StreetSnapDraftImage{
DraftID: id,
Image: img,
Image: fi.url,
Name: fmt.Sprintf("Look %d", i+1),
SortOrder: uint32(i + 1),
Phash: phs[i],
ContentSha1: fi.sha1,
Phash: sqlNull(fi.phash),
IsDuplicate: fi.isDup,
DupOf: strconv.FormatUint(uint64(fi.dupID), 10),
})
}
// 3.5) 全局近似去重标记(与走秀同逻辑,只拦新增、留痕不删)。
s.tagDuplicates(ctx, phs, func(i int, dupOf string) {
rows[i].IsDuplicate = 1
rows[i].DupOf = dupOf
})
if err := s.repo.CreateStreetSnapDraftImages(ctx, rows); err != nil {
s.failOrRetry(ctx, job.ID, "create street draft images: "+err.Error())
@ -426,30 +439,48 @@ func (s *IngestService) processStreet(ctx context.Context, job model.IngestJob,
_ = s.repo.MarkDone(ctx, job.ID)
}
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 全部图片地址, 各图 pHash, 已成功上传对象的 key 列表, 是否有任意一张失败)。
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 全部图片地址, 已成功上传对象的 key 列表, 是否有任意一张失败)。
// prefix 为七牛 key 前缀(runway/ 或 street/)。只要任意一张下载/上传失败,failed 即置 true,
// 调用方据此把整条任务判为失败(不再写草稿),并拿 keys 回滚本批已上传的对象,符合「单图失败=整任务失败」策略。
// 返回的 pHash 与图片地址按索引对齐(phash=0 表示解码失败未计算,比对时跳过)。
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix string) (string, []string, []uint64, []string, bool) {
// fetchImages 下载图片并上传到存储,返回 (cover 地址, 已下载图结构, 已成功上传对象的 key 列表, 是否有任意一张失败)。
// prefix 为七牛 key 前缀(runway/ 或 street/)。kind 用于选择去重比对表。
// 只要任意一张下载/上传失败,failed 即置 true,调用方据此把整条任务判为失败并回滚本批已上传的对象,
// 符合「单图失败=整任务失败」策略。每张图入库前做去重(精确跳过 + 近重复标记)。
func (s *IngestService) fetchImages(ctx context.Context, urls []string, prefix, kind string) (string, []fetchedImage, []string, bool) {
cover := ""
out := make([]string, 0, len(urls))
phashes := make([]uint64, 0, len(urls))
out := make([]fetchedImage, 0, len(urls))
keys := make([]string, 0, len(urls))
seen := make(map[string]bool)
failed := false
for i, u := range urls {
url, key, ph, err := s.downloadOne(ctx, u, i, prefix)
for _, u := range urls {
url, key, sha1h, ph, err := s.downloadOne(ctx, u, 0, prefix)
if err != nil {
failed = true
continue
}
if i == 0 {
keys = append(keys, key)
if seen[sha1h] {
continue
}
seen[sha1h] = true
skip, dupID, isDup := s.dedupImage(ctx, kind, sha1h, ph)
if skip {
continue
}
if cover == "" {
cover = url
}
out = append(out, url)
phashes = append(phashes, ph)
keys = append(keys, key)
out = append(out, fetchedImage{
url: url,
key: key,
sha1: sha1h,
phash: ph,
dupID: dupID,
isDup: isDup,
})
}
return cover, out, phashes, keys, failed
return cover, out, keys, failed
}
// cleanupUploads 删除一批本批次成功上传的对象(七牛 + 本地兜底),用于任务失败回滚:
@ -471,21 +502,65 @@ func (s *IngestService) cleanupUploads(ctx context.Context, keys []string) {
}
}
// fetchedImage 是单张已下载图片的暂存结构,携带去重所需的指纹信息。
type fetchedImage struct {
url string
key string
sha1 string // 内容 sha1(与存储 key 同源)
phash string // dHash 的 pgvector 二进制向量串,空串表示无法解码
dupID uint32 // 命中近重复时的参考图 id
isDup uint8 // 是否标记为近重复(供审核留痕)
}
// sqlNull 把 phash 字符串转成可空向量字段:空串 → NULL(不参与近邻检索)。
func sqlNull(ph string) sql.NullString {
return sql.NullString{String: ph, Valid: ph != ""}
}
// dedupImage 判断单张图是否重复:
// - 精确重复(content_sha1 已在库)→ 返回 skip=true(不入库该行);
// - 近重复(dHash 汉明距离 ≤ 阈值)→ 仍入库,但标记 is_duplicate + dup_of;
// - 否则正常入库。
func (s *IngestService) dedupImage(ctx context.Context, kind, sha1hash, phashBits string) (skip bool, dupID uint32, isDup uint8) {
// repo 未注入(如离线单测)时跳过去重,不阻断主流程。生产环境 repo 必不为空。
if s.repo == nil {
return false, 0, 0
}
var tables []string
switch kind {
case dto.IngestKindRunway:
tables = []string{"brand_runway_draft_images", "brand_runway_images"}
case dto.IngestKindStreet:
tables = []string{"street_snap_draft_images", "street_snap_images"}
default:
return false, 0, 0
}
if exists, err := s.repo.ImageExistsBySha1(ctx, tables, sha1hash); err == nil && exists {
return true, 0, 0
}
if phashBits != "" {
if id, found, err := s.repo.FindNearDuplicateImage(ctx, tables, phashBits, phash.DefaultThreshold); err == nil && found {
return false, id, 1
}
}
return false, 0, 0
}
// downloadOne 把单张远程图下载后上传到存储(七牛优先,失败兜底本地),
// 返回 (访问地址, 对象 key, 感知哈希, error)。访问地址可直接写入数据库
// (七牛为完整 https URL,本地为相对 /uploads 路径);感知哈希基于原始字节算一次,供入库时近似去重。
func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, prefix string) (string, string, uint64, error) {
// 返回 (访问地址, 对象 key, 内容 sha1, dHash 向量串, error)。访问地址可直接写入数据库
// (七牛为完整 https URL,本地为相对 /uploads 路径)。
func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, prefix string) (string, string, string, string, error) {
resp, err := s.httpClient.Get(u)
if err != nil {
return "", "", 0, err
return "", "", "", "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", 0, fmt.Errorf("status %d", resp.StatusCode)
return "", "", "", "", fmt.Errorf("status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", 0, err
return "", "", "", "", err
}
ext := path.Ext(u)
if ext == "" || len(ext) > 5 {
@ -498,74 +573,21 @@ func (s *IngestService) downloadOne(ctx context.Context, u string, idx int, pref
key := fmt.Sprintf("%s/%s%s", prefix, hash, ext)
ct := resp.Header.Get("Content-Type")
// 感知哈希:同一份字节在此算一次(decode 失败时 ph=0,比对时跳过,不阻断入库)。
// 计算 dHash 指纹(仅用于近重复检索;解码失败返回空串 → NULL,不参与检索)。
ph := phash.Of(data)
if ph == 0 {
log.Printf("[warn] ingest pHash 未计算(解码失败或非 jpeg/png/gif 格式) url=%s", u)
}
phashBits := phash.ToVectorBits(ph)
// 主上传器(七牛)
if url, err := s.uploader.Upload(ctx, key, data, ct); err == nil {
return url, key, ph, nil
return url, key, hash, phashBits, nil
} else if s.local != nil {
// 兜底本地,避免图片完全丢失
if lurl, lerr := s.local.Upload(ctx, key, data, ct); lerr == nil {
return lurl, key, ph, nil
return lurl, key, hash, phashBits, nil
} else {
return "", "", 0, err
return "", "", "", "", err
}
}
return "", "", 0, err
return "", "", "", "", err
}
// tagDuplicates 对一批图片(phashList 与 apply 下标对齐)做全局近似去重标记:
// 与「已晋升图片」的 phash 库比汉明距离(≤ phash.DefaultThreshold 即近似重复),
// 命中则调用 apply(i, dupOf) 由调用方把第 i 张图标 IsDuplicate=1、DupOf=命中图 uid。
// 不丢弃任何图、仅留痕(交后台人工裁决),契合「只拦新增、全局跨所有图、标记不删」。
// 比对库拉取失败仅告警并跳过(不阻断入库);phash=0 的图不比对、不误杀。
func (s *IngestService) tagDuplicates(ctx context.Context, phashList []uint64, apply func(i int, dupOf string)) {
uids, err := s.matchDuplicates(ctx, phashList)
if err != nil {
log.Printf("[warn] ingest 近似去重比对失败 err=%v(跳过标记,不阻断入库)", err)
return
}
for i, u := range uids {
if u != "" {
apply(i, u)
}
}
}
// matchDuplicates 返回与 phashList 等长的 dup_of uid 切片(""=未命中近似重复)。
// 对每张新图,在已晋升图片库里找汉明距离 ≤ 阈值的命中,取距离最近者,
// 按其 kind(runway/street)编码成对应 hashid 类型作为 dup_of。
func (s *IngestService) matchDuplicates(ctx context.Context, phashList []uint64) ([]string, error) {
refs, err := s.repo.ListImagePHashes(ctx)
if err != nil {
return nil, err
}
out := make([]string, len(phashList))
for i, ph := range phashList {
if ph == 0 {
continue // 未计算 phash 的图不比对(避免误杀)
}
bestUID := ""
bestDist := phash.DefaultThreshold + 1
for _, ref := range refs {
if ref.Phash == 0 {
continue
}
d := phash.Hamming(ph, ref.Phash)
if d <= phash.DefaultThreshold && d < bestDist {
var typ byte = hashid.TypeRunwayImage
if ref.Kind == "street" {
typ = hashid.TypeSnapImage
}
bestUID = hashid.EncodeWithType(ref.ID, typ)
bestDist = d
}
}
out[i] = bestUID
}
return out, nil
}

View File

@ -3,14 +3,14 @@ package main
import (
"fmt"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
const dsn = "root:root@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local"
const dsn = "postgres://fashion:fashion_dev_2026@127.0.0.1:5432/fashion?sslmode=disable"
func main() {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
panic(err)
}

View File

@ -5,14 +5,14 @@ package main
import (
"fmt"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
const dsn = "root:root@tcp(127.0.0.1:3306)/db?charset=utf8mb4&parseTime=True&loc=Local"
const dsn = "postgres://fashion:fashion_dev_2026@127.0.0.1:5432/fashion?sslmode=disable"
func main() {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
panic(err)
}

View File

@ -1,7 +1,7 @@
// Command seed_users 预置内部账号(运营/编辑用,不开放公开注册)。
//
// 项目已移除 AutoMigrate,表结构由 scripts/sql/005_create_users.sql 托管;
// 本脚本用 CREATE TABLE IF NOT EXISTS 兜底建表,再 upsert 一个内部账号。
// 项目表结构由 GORM AutoMigrate 托管(服务启动时创建);
// 本脚本仅在独立运行、尚未跑过服务时兜底建表,再 upsert 一个内部账号。
//
// 用法:
// go run ./scripts/seed_users
@ -43,19 +43,19 @@ func main() {
}
}()
// 1. 兜底建表(与 scripts/sql/005_create_users.sql 完全一致)
// 1. 兜底建表(与 User 模型保持一致;正式结构由服务启动时 AutoMigrate 创建,此处仅作独立运行兜底)
createSQL := `CREATE TABLE IF NOT EXISTS users (
id int unsigned NOT NULL AUTO_INCREMENT,
created_at int unsigned NOT NULL DEFAULT 0,
updated_at int unsigned NOT NULL DEFAULT 0,
id bigserial PRIMARY KEY,
created_at bigint NOT NULL DEFAULT 0,
updated_at bigint NOT NULL DEFAULT 0,
username varchar(64) NOT NULL,
email varchar(191) NOT NULL,
password_hash varchar(255) NOT NULL DEFAULT '',
is_deleted tinyint unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_username (username),
UNIQUE KEY uk_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`
is_deleted smallint NOT NULL DEFAULT 0,
tier varchar(16) NOT NULL DEFAULT 'free',
UNIQUE (username),
UNIQUE (email)
);`
if e := db.Exec(createSQL).Error; e != nil {
log.Fatalf("✗ 建表失败: %v", e)
}

View File

@ -1,79 +0,0 @@
-- ============================================================================
-- 002_i18n_columns.sql
-- 方案 A(独立列):把品牌名 / 走秀标题 / 描述 拆分为 en / cn 两列。
--
-- 设计前提(已用探查脚本核对线上数据):
-- * brand.name 多为拉丁名,但 266 个含中文;show_name 形如 "拉丁/中文"(2025 个含中文)。
-- * brand_runway.title / description 校验过 han=0,当前全为英文。
-- * 排序 / A-Z 索引 / 关键词搜索 统一基于 name_en(拉丁),中文品牌(name_en 为空)归入 OTHER 桶。
--
-- 回填策略:
-- name_en = name 去掉中文后的部分;若 name 全中文则取 show_name 斜杠前部分。
-- name_cn = show_name 斜杠后含中文的部分;否则 show_name 本身(含中文);否则 name(含中文)。
-- title_en / description_en = 原 title / description;title_cn / description_cn = ''(当前无中文)。
--
-- 旧列 name / show_name / title / description 保留不删,作为回退与对比;
-- 代码全部改用 *_en / *_cn 后,可择机在后续迁移中 DROP。
-- ============================================================================
ALTER TABLE brand
ADD COLUMN name_en VARCHAR(255) NOT NULL DEFAULT '' AFTER name,
ADD COLUMN name_cn VARCHAR(255) NOT NULL DEFAULT '' AFTER name_en;
ALTER TABLE brand_runway
ADD COLUMN title_en VARCHAR(255) NOT NULL DEFAULT '' AFTER title,
ADD COLUMN title_cn VARCHAR(255) NOT NULL DEFAULT '' AFTER title_en,
ADD COLUMN description_en VARCHAR(1024) NOT NULL DEFAULT '' AFTER description,
ADD COLUMN description_cn VARCHAR(1024) NOT NULL DEFAULT '' AFTER description_en;
-- 回填品牌名:拆拉丁 / 中文
UPDATE brand SET
name_en = CASE
WHEN TRIM(REGEXP_REPLACE(name, '[一-鿿]', '')) <> '' THEN TRIM(REGEXP_REPLACE(name, '[一-鿿]', ''))
WHEN show_name LIKE '%/%' THEN TRIM(SUBSTRING_INDEX(show_name, '/', 1))
ELSE ''
END,
name_cn = CASE
WHEN show_name LIKE '%/%' AND SUBSTRING_INDEX(show_name, '/', -1) REGEXP '[一-鿿]'
THEN TRIM(SUBSTRING_INDEX(show_name, '/', -1))
WHEN show_name REGEXP '[一-鿿]' THEN TRIM(show_name)
WHEN name REGEXP '[一-鿿]' THEN TRIM(name)
ELSE ''
END;
-- 回填走秀:当前 title / description 全为英文,直接进 *_en
UPDATE brand_runway SET
title_en = title,
description_en = description,
title_cn = '',
description_cn = '';
-- ============================================================================
-- 第 5 步(依赖「分类字段回填迁移」之后执行):title_cn 规则生成
--
-- 前提:collection_type / season / year 已由历史迁移解析回填(rtw/menswear/
-- couture/resort/pre_fall × spring/fall/空)。title 本质是「季节/品类描述」,
-- 品牌名在 JOIN 的 brand_name 里单独给,因此 title_cn 可纯规则生成,
-- 完全不依赖外部翻译服务,结果稳定可重跑。
--
-- 映射:resort→「{year} 度假系列」、pre_fall→「{year} 早秋系列」、
-- rtw→「{year} {春夏|秋冬}成衣」、menswear→「{year} {春夏|秋冬}男装」、
-- couture→「{year} {春夏|秋冬}高定」。
--
-- 执行器:scripts/backfill_title_cn/main.go(幂等,整表覆盖重跑无害)。
-- 该步骤与上方 i18n 加列/回填相互独立,可单独重跑。
-- ============================================================================
UPDATE brand_runway
SET title_cn = CASE
WHEN collection_type = 'resort' THEN CONCAT(year, ' 度假系列')
WHEN collection_type = 'pre_fall' THEN CONCAT(year, ' 早秋系列')
WHEN collection_type = 'rtw' AND season = 'spring' THEN CONCAT(year, ' 春夏成衣')
WHEN collection_type = 'rtw' AND season = 'fall' THEN CONCAT(year, ' 秋冬成衣')
WHEN collection_type = 'rtw' THEN CONCAT(year, ' 成衣')
WHEN collection_type = 'menswear' AND season = 'spring' THEN CONCAT(year, ' 春夏男装')
WHEN collection_type = 'menswear' AND season = 'fall' THEN CONCAT(year, ' 秋冬男装')
WHEN collection_type = 'couture' AND season = 'spring' THEN CONCAT(year, ' 春夏高定')
WHEN collection_type = 'couture' AND season = 'fall' THEN CONCAT(year, ' 秋冬高定')
ELSE CONCAT(year, ' 系列')
END
WHERE is_deleted = 0;

View File

@ -1,8 +0,0 @@
-- 003_drop_brand_alias.sql
-- 删除 brand_alias 孤儿表。
-- 背景:i18n 方案 A 落地后,brand.name_cn / name_en 已成为品牌名权威来源;
-- brand_alias 全代码库零引用,且 99.9% 中文记录与 brand.name_cn 完全重复,
-- 保留它只会成为名字的第二来源、后期悄然分叉。故删除。
-- 注意:本文件为审阅底稿,实际执行由 scripts/i18n/drop_alias/main.go 驱动(无 mysql 客户端环境)。
DROP TABLE IF EXISTS brand_alias;

View File

@ -1,4 +0,0 @@
-- 街拍地区筛选:street_snap 新增 city 列(从 title 第一段 \t 解析回填)
-- 对应执行器 scripts/backfill_city/main.go(幂等,可重跑)
-- city 存「地点」原文(如 Paris / 巴黎 / New York),'' 表示未归类,不参与地区筛选
ALTER TABLE street_snap ADD COLUMN city VARCHAR(128) NOT NULL DEFAULT '' AFTER year;

View File

@ -1,14 +0,0 @@
-- 账号体系用户表(内部运营/编辑账号,不开放公开注册)。
-- 与 scripts/seed_users/main.go 中内置的建表语句保持一致。
CREATE TABLE IF NOT EXISTS `users` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`created_at` int unsigned NOT NULL DEFAULT 0,
`updated_at` int unsigned NOT NULL DEFAULT 0,
`username` varchar(64) NOT NULL,
`email` varchar(191) NOT NULL,
`password_hash` varchar(255) NOT NULL DEFAULT '',
`is_deleted` tinyint unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -1,15 +0,0 @@
-- 006: refresh_tokens —— 刷新令牌表(双令牌续期 + 踢下线的服务端状态)
-- 与项目约定一致:表结构由 SQL 托管,不依赖 GORM AutoMigrate。
CREATE TABLE IF NOT EXISTS refresh_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
token_hash VARCHAR(64) NOT NULL,
expires_at INT UNSIGNED NOT NULL DEFAULT 0,
revoked TINYINT UNSIGNED NOT NULL DEFAULT 0,
user_agent VARCHAR(255) NOT NULL DEFAULT '',
ip VARCHAR(64) NOT NULL DEFAULT '',
created_at INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_token_hash (token_hash),
KEY idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -1,17 +0,0 @@
-- 007: favorites —— 用户收藏(服务端持久化,按用户隔离)
-- 与项目约定一致:表结构由 SQL 托管,不依赖 GORM AutoMigrate。
-- target_uid 存前端卡片的 it.id(编码串),与前端直接对齐;title/cover/brand 为收藏时快照。
CREATE TABLE IF NOT EXISTS `favorites` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`created_at` int unsigned NOT NULL DEFAULT 0,
`user_id` int unsigned NOT NULL,
`target_type` varchar(16) NOT NULL DEFAULT '',
`target_uid` varchar(64) NOT NULL DEFAULT '',
`title` varchar(255) NOT NULL DEFAULT '',
`cover` varchar(512) NOT NULL DEFAULT '',
`brand` varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_user_target` (`user_id`, `target_uid`),
KEY `idx_user` (`user_id`),
KEY `idx_user_created` (`user_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -1,21 +0,0 @@
-- 008: histories —— 用户浏览历史(服务端持久化,按用户隔离,跨设备一致)
-- 与 favorites 同理:表结构由 SQL 托管,不依赖 GORM AutoMigrate。
--
-- 2026-09-02 收敛为「文章级」浏览历史:
-- 1) 触发点:点开某篇走秀/街拍详情(/item/xxx)时记一条;进入列表页不再记。
-- 2) 仅存 (user_id, target_uid, viewed_at):target_uid 为带类型前缀的对外编码串
-- (runway r=xxx / street s=xxx,见后端 hashid.EncodeTyped),前缀本身携带类型,
-- 无需 kind / target_type 列。
-- 3) title/cover/brand 快照已移除:账户页展示时按 target_uid 回查公开详情接口,
-- 保持表最简、展示数据新鲜。
-- 唯一键 (user_id, target_uid):同一篇文章反复打开只 upsert 刷新 viewed_at,绝不新增行。
-- 每用户上限 HISTORY_CAP(应用层维护,默认 2000),超出 FIFO 删最旧。
CREATE TABLE IF NOT EXISTS `histories` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`viewed_at` int unsigned NOT NULL DEFAULT 0,
`user_id` int unsigned NOT NULL,
`target_uid` varchar(64) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_user_target` (`user_id`, `target_uid`),
KEY `idx_user_viewed` (`user_id`, `viewed_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;