This commit is contained in:
toom1996
2026-09-13 14:16:37 +08:00
parent b4a0c21ce1
commit 580697f17a
2 changed files with 274 additions and 0 deletions

View File

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

121
internal/pkg/storage/s4.go Normal file
View File

@ -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://<bucket>.s3.bitiful.net/<key>。
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://<bucket>.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://<bucket>.s3.bitiful.net/<key>
})
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
}