update
This commit is contained in:
153
internal/pkg/pagination/pagination.go
Normal file
153
internal/pkg/pagination/pagination.go
Normal 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
121
internal/pkg/storage/s4.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user