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