diff --git a/internal/pkg/pagination/pagination.go b/internal/pkg/pagination/pagination.go new file mode 100644 index 0000000..ab42fa4 --- /dev/null +++ b/internal/pkg/pagination/pagination.go @@ -0,0 +1,153 @@ +// Package pagination 提供后端分页的通用封装:查询参数解析、分页元数据计算、SQL 偏移量, +// 以及后台 HTML 模板可复用的翻页链接构造。 +// +// 目标:消除各 handler 里重复的「page/size 解析 + 上/下一页计算 + 越界钳制」样板, +// 同时保证对外协议(JSON 字段名、HTML 展示文案)完全不变。 +package pagination + +import ( + "strconv" + + "github.com/gin-gonic/gin" +) + +// Params 是一页请求的规范化参数(page 已钳制到 >=1;size 已钳制到 [1, maxSize])。 +type Params struct { + Page int + PageSize int +} + +// Parse 从 gin 查询串读取并规范化分页参数。 +// +// pageKey/sizeKey 为查询参数名;sizeKey 为空表示不读取条数(固定用 defSize,不校验上限)。 +// 解析规则(与既有后台列表页、history/favorite 接口保持一致): +// - page 缺失或非数字 → defPage;<1 → defPage +// - size 缺失或非数字 → defSize;<1 → defSize;>maxSize → maxSize +func Parse(c *gin.Context, pageKey, sizeKey string, defPage, defSize, maxSize int) Params { + page := atoiDefault(c.Query(pageKey), defPage) + if page < 1 { + page = defPage + } + size := defSize + if sizeKey != "" { + size = atoiDefault(c.Query(sizeKey), defSize) + if size < 1 { + size = defSize + } + if maxSize > 0 && size > maxSize { + size = maxSize + } + } + return Params{Page: page, PageSize: size} +} + +// Offset 返回 GORM 翻页用的 SQL 偏移量:(page-1)*size。 +func (p Params) Offset() int { return (p.Page - 1) * p.PageSize } + +// Meta 是分页元数据(总页数、上/下一页、是否可翻)。 +// +// NewMeta 不会修改传入的 Page(与既有 brands/runways/street/reviews 行为一致:越界页码 +// 照常透传给数据库,返回空列表,仅 HasNext 据此计算)。需要越界钳制显示(ingest-jobs) +// 的调用方拿到 Meta 后调用 Clamp。 +type Meta struct { + Page int + PageSize int + Total int64 + TotalPages int + PrevPage int + NextPage int + HasPrev bool + HasNext bool +} + +// NewMeta 计算分页元数据。 +// +// HasNext 等价于「page < 总页数」,与原有 `int64(page*size) < total` 在 total>0 时一致; +// total==0 时总页数记为 1,HasNext 恒为 false(与原逻辑相同)。 +func (p Params) NewMeta(total int64) Meta { + totalPages := LastPage(total, p.PageSize) + m := Meta{ + Page: p.Page, + PageSize: p.PageSize, + Total: total, + TotalPages: totalPages, + } + if p.Page > 1 { + m.PrevPage = p.Page - 1 + m.HasPrev = true + } + if p.Page < totalPages { + m.NextPage = p.Page + 1 + m.HasNext = true + } + return m +} + +// Clamp 把页码钳制到 [1, TotalPages],并重新计算 Prev/Next。 +// 用于「越界页码应展示最后一页」的场景(ingest-jobs),不改变数据库查询用的原始 offset。 +func (m *Meta) Clamp() { + if m.TotalPages > 0 && m.Page > m.TotalPages { + m.Page = m.TotalPages + m.HasPrev = m.Page > 1 + m.HasNext = false + m.PrevPage = 0 + m.NextPage = 0 + if m.HasPrev { + m.PrevPage = m.Page - 1 + } + } +} + +// LastPage 计算总页数;无数据时返回 1(与原项目一致,避免前端出现 0 页)。 +func LastPage(total int64, size int) int { + if size <= 0 { + return 1 + } + last := int(total) / size + if int(total)%size > 0 { + last++ + } + if last == 0 { + last = 1 + } + return last +} + +// Pager 是后台 HTML 模板复用的翻页视图。 +// +// 由 handler 用 PageLink/PageLinkWith 生成 PrevURL/NextURL 后塞进模板数据, +// 模板只需写一行 {{template "pager" .Pager}}。 +type Pager struct { + PrevURL string + NextURL string + HasPrev bool + HasNext bool + Label string // 页码信息文案(如「第 3 页 · 共 120 条」),可为空 +} + +// PageLink 基于当前请求构造仅替换 page 的翻页链接,保留其余查询参数。 +// 用于 brands/runways/street/reviews 等(URL 路径本身已含 kind 段,无需额外拼接)。 +func PageLink(c *gin.Context, page int) string { + q := c.Request.URL.Query() + q.Set("page", strconv.Itoa(page)) + return c.Request.URL.Path + "?" + q.Encode() +} + +// PageLinkWith 除替换 page 外,额外固定一个参数(如 ingest-jobs 的 pageSize)。 +func PageLinkWith(c *gin.Context, page int, extraKey string, extraVal int) string { + q := c.Request.URL.Query() + q.Set("page", strconv.Itoa(page)) + q.Set(extraKey, strconv.Itoa(extraVal)) + return c.Request.URL.Path + "?" + q.Encode() +} + +func atoiDefault(s string, def int) int { + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} diff --git a/internal/pkg/storage/s4.go b/internal/pkg/storage/s4.go new file mode 100644 index 0000000..4a7b1ba --- /dev/null +++ b/internal/pkg/storage/s4.go @@ -0,0 +1,121 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// s4DefaultEndpoint 缤纷云 S4 的 S3 兼容接入点(官方文档确认 https://s3.bitiful.net)。 +// 采用虚拟主机风格寻址,请求落在 https://.s3.bitiful.net/。 +const s4DefaultEndpoint = "https://s3.bitiful.net" + +// S4Uploader 缤纷云 S4 对象存储上传器(兼容 S3 协议,基于 aws-sdk-go-v2)。 +// +// 图片经 CoreIX 公开样式(如 key!style:high)对外提供,VIP 与免费用户同质量、匿名可访问。 +// 库里只存对象 key(如 runway/abc.jpg),完整 URL 由 imgurl.Composer 拼装。 +// 本类型同时实现 storage.Uploader 与 storage.Deleter。 +type S4Uploader struct { + client *s3.Client + bucket string + baseURL string + enabled bool +} + +// NewS4Uploader 构造缤纷云 S4 上传器。 +// - ak/sk:缤纷云子账户 AccessKey/SecretKey(须有 PutObject/DeleteObject/GetObject 权限)。 +// - bucket:存储桶名(须为「公开桶」,图片经 CoreIX 公开样式对外提供)。 +// - endpoint:S3 接入点,默认 https://s3.bitiful.net;虚拟主机风格寻址。 +// - baseURL:对外访问域名(GET 图片用)。为空时自动推导为 https://.s3.bitiful.net, +// 须与该 S4 接入点的虚拟主机域名一致,保证展示图 URL 同源。 +// - region:任意值(如 cn-east-1),缤纷云不校验。 +func NewS4Uploader(ak, sk, bucket, endpoint, baseURL, region string) *S4Uploader { + if ak == "" || sk == "" || bucket == "" { + return &S4Uploader{enabled: false} + } + if endpoint == "" { + endpoint = s4DefaultEndpoint + } + if region == "" { + region = "cn-east-1" + } + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion(region), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(ak, sk, "")), + ) + if err != nil { + return &S4Uploader{enabled: false} + } + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(endpoint) + o.UsePathStyle = false // 虚拟主机风格:https://.s3.bitiful.net/ + }) + if baseURL == "" { + baseURL = deriveS4BaseURL(endpoint, bucket) + } + return &S4Uploader{ + client: client, + bucket: bucket, + baseURL: strings.TrimRight(baseURL, "/"), + enabled: true, + } +} + +// deriveS4BaseURL 由 endpoint + bucket 推导虚拟主机风格访问域名: +// endpoint=https://s3.bitiful.net, bucket=toom → https://toom.s3.bitiful.net +func deriveS4BaseURL(endpoint, bucket string) string { + endpoint = strings.TrimRight(endpoint, "/") + scheme := "https://" + host := endpoint + if strings.HasPrefix(host, "https://") { + host = host[len("https://"):] + } else if strings.HasPrefix(host, "http://") { + scheme = "http://" + host = host[len("http://"):] + } + return scheme + bucket + "." + host +} + +// PublicBaseURL 返回对外访问域名(可能由 NewS4Uploader 自动推导)。 +func (s *S4Uploader) PublicBaseURL() string { return s.baseURL } + +// Enabled 凭证齐全才视为启用。 +func (s *S4Uploader) Enabled() bool { return s.enabled } + +// Upload 把 data 直传到缤纷云 bucket 的 key,返回 key(库里只存 key,baseURL 由 Composer 拼)。 +func (s *S4Uploader) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) { + if !s.enabled { + return "", fmt.Errorf("s4 uploader not enabled") + } + input := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + ContentType: aws.String(contentType), + } + if _, err := s.client.PutObject(ctx, input); err != nil { + return "", fmt.Errorf("s4 put object failed: %w", err) + } + return key, nil +} + +// Delete 删除缤纷云 bucket 中的对象(按 key)。key 可能带 /uploads/ 等前缀,先剥离。 +func (s *S4Uploader) Delete(ctx context.Context, key string) error { + if !s.enabled { + return nil + } + key = strings.TrimPrefix(key, "/") + if _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }); err != nil { + return fmt.Errorf("s4 delete object failed: %w", err) + } + return nil +}