improve image sharing function

This commit is contained in:
2025-02-20 23:04:58 +08:00
parent b196e50aee
commit db4c59f6f6
37 changed files with 2188 additions and 95 deletions

View File

@@ -467,15 +467,15 @@ type (
}
// 相册列表请求参数
AlbumListRequest {
Type string `json:"type"`
Sort bool `json:"sort"`
Type int64 `json:"type,omitempty"`
Sort bool `json:"sort"`
}
// 相册列表响应参数
Album {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
Type string `json:"type"`
Type int64 `json:"type"`
CoverImage string `json:"cover_image"`
}
AlbumListResponse {
@@ -593,6 +593,18 @@ type (
SingleImageRequest {
ID int64 `json:"id"`
}
StorageMeta {
Name string `json:"name"`
Value string `json:"value"`
}
StroageNode {
Value string `json:"value"`
Name string `json:"name"`
Children []StorageMeta `json:"children"`
}
StorageListResponse {
Records []StroageNode `json:"records"`
}
)
// 文件上传
@@ -679,5 +691,88 @@ service auth {
// 获取单张图片连接
@handler getImageUrl
post /image/url/single (SingleImageRequest) returns (string)
// 获取用户存储配置列表
@handler getUserStorageList
post /user/config/list returns (StorageListResponse)
}
type (
ShareImageMeta {
FileName string `json:"file_name"`
OriginImage string `json:"origin_image"`
FileType string `json:"file_type"`
Thumbnail string `json:"thumbnail"`
ThumbW float64 `json:"thumb_w"`
ThumbH float64 `json:"thumb_h"`
ThumbSize int64 `json:"thumb_size"`
}
ShareImageRequest {
Title string `json:"title,omitempty"`
ExpireDate string `json:"expire_date"`
AccessLimit int64 `json:"access_limit,omitempty"`
AccessPassword string `json:"access_password,omitempty"`
Provider string `json:"provider"`
Bucket string `json:"bucket"`
Images []ShareImageMeta `json:"images"`
}
QueryShareImageRequest {
ShareCode string `json:"share_code"`
AccessPassword string `json:"access_password,omitempty"`
}
ShareImageListMeta {
ID int64 `json:"id"`
FileName string `json:"file_name"`
URL string `json:"url"`
Thumbnail string `json:"thumbnail"`
ThumbW float64 `json:"thumb_w"`
ThumbH float64 `json:"thumb_h"`
ThumbSize float64 `json:"thumb_size"`
CreatedAt string `json:"created_at"`
}
QueryShareImageResponse {
List []ShareImageListMeta `json:"list"`
}
ShareRecordListRequest {
DateRange []string `json:"date_range"`
}
// 分享记录列表响应参数
ShareRecord {
ID int64 `json:"id"`
CoverImage string `json:"cover_image"`
CreatedAt string `json:"created_at"`
ShareCode string `json:"share_code"`
VisitLimit int64 `json:"visit_limit"`
AccessPassword string `json:"access_password"`
ValidityPeriod int64 `json:"validity_period"`
}
ShareRecordListResponse {
records []ShareRecord `json:"records"`
}
)
// 分享服务
@server (
group: share // 微服务分组
prefix: /api/auth/share // 微服务前缀
timeout: 10s // 超时时间
maxBytes: 104857600 // 最大请求大小
signature: false // 是否开启签名验证
middleware: SecurityHeadersMiddleware,CasbinVerifyMiddleware,NonceMiddleware // 注册中间件
MaxConns: true // 是否开启最大连接数限制
Recover: true // 是否开启自动恢复
jwt: Auth // 是否开启jwt验证
)
service auth {
@handler uploadShareImage
post /upload (ShareImageRequest) returns (string)
//查看分享图片
@handler queryShareImage
post /image/list (QueryShareImageRequest) returns (QueryShareImageResponse)
// 列出分享记录
@handler listShareRecord
post /record/list (ShareRecordListRequest) returns (ShareRecordListResponse)
}

View File

@@ -11,6 +11,7 @@ import (
client "schisandra-album-cloud-microservices/app/auth/api/internal/handler/client"
comment "schisandra-album-cloud-microservices/app/auth/api/internal/handler/comment"
oauth "schisandra-album-cloud-microservices/app/auth/api/internal/handler/oauth"
share "schisandra-album-cloud-microservices/app/auth/api/internal/handler/share"
sms "schisandra-album-cloud-microservices/app/auth/api/internal/handler/sms"
storage "schisandra-album-cloud-microservices/app/auth/api/internal/handler/storage"
token "schisandra-album-cloud-microservices/app/auth/api/internal/handler/token"
@@ -158,6 +159,33 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithMaxBytes(1048576),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.SecurityHeadersMiddleware, serverCtx.CasbinVerifyMiddleware, serverCtx.NonceMiddleware},
[]rest.Route{
{
Method: http.MethodPost,
Path: "/image/list",
Handler: share.QueryShareImageHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/record/list",
Handler: share.ListShareRecordHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/upload",
Handler: share.UploadShareImageHandler(serverCtx),
},
}...,
),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),
rest.WithPrefix("/api/auth/share"),
rest.WithTimeout(10000*time.Millisecond),
rest.WithMaxBytes(104857600),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.SecurityHeadersMiddleware, serverCtx.NonceMiddleware},
@@ -278,6 +306,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/uploads",
Handler: storage.UploadFileHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/user/config/list",
Handler: storage.GetUserStorageListHandler(serverCtx),
},
}...,
),
rest.WithJwt(serverCtx.Config.Auth.AccessSecret),

View File

@@ -0,0 +1,29 @@
package share
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"schisandra-album-cloud-microservices/app/auth/api/internal/logic/share"
"schisandra-album-cloud-microservices/app/auth/api/internal/svc"
"schisandra-album-cloud-microservices/app/auth/api/internal/types"
"schisandra-album-cloud-microservices/common/xhttp"
)
func ListShareRecordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ShareRecordListRequest
if err := httpx.Parse(r, &req); err != nil {
xhttp.JsonBaseResponseCtx(r.Context(), w, err)
return
}
l := share.NewListShareRecordLogic(r.Context(), svcCtx)
resp, err := l.ListShareRecord(&req)
if err != nil {
xhttp.JsonBaseResponseCtx(r.Context(), w, err)
} else {
xhttp.JsonBaseResponseCtx(r.Context(), w, resp)
}
}
}

View File

@@ -0,0 +1,29 @@
package share
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"schisandra-album-cloud-microservices/app/auth/api/internal/logic/share"
"schisandra-album-cloud-microservices/app/auth/api/internal/svc"
"schisandra-album-cloud-microservices/app/auth/api/internal/types"
"schisandra-album-cloud-microservices/common/xhttp"
)
func QueryShareImageHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.QueryShareImageRequest
if err := httpx.Parse(r, &req); err != nil {
xhttp.JsonBaseResponseCtx(r.Context(), w, err)
return
}
l := share.NewQueryShareImageLogic(r.Context(), svcCtx)
resp, err := l.QueryShareImage(&req)
if err != nil {
xhttp.JsonBaseResponseCtx(r.Context(), w, err)
} else {
xhttp.JsonBaseResponseCtx(r.Context(), w, resp)
}
}
}

View File

@@ -0,0 +1,29 @@
package share
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"schisandra-album-cloud-microservices/app/auth/api/internal/logic/share"
"schisandra-album-cloud-microservices/app/auth/api/internal/svc"
"schisandra-album-cloud-microservices/app/auth/api/internal/types"
"schisandra-album-cloud-microservices/common/xhttp"
)
func UploadShareImageHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ShareImageRequest
if err := httpx.Parse(r, &req); err != nil {
xhttp.JsonBaseResponseCtx(r.Context(), w, err)
return
}
l := share.NewUploadShareImageLogic(r.Context(), svcCtx)
resp, err := l.UploadShareImage(&req)
if err != nil {
xhttp.JsonBaseResponseCtx(r.Context(), w, err)
} else {
xhttp.JsonBaseResponseCtx(r.Context(), w, resp)
}
}
}

View File

@@ -0,0 +1,21 @@
package storage
import (
"net/http"
"schisandra-album-cloud-microservices/app/auth/api/internal/logic/storage"
"schisandra-album-cloud-microservices/app/auth/api/internal/svc"
"schisandra-album-cloud-microservices/common/xhttp"
)
func GetUserStorageListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := storage.NewGetUserStorageListLogic(r.Context(), svcCtx)
resp, err := l.GetUserStorageList()
if err != nil {
xhttp.JsonBaseResponseCtx(r.Context(), w, err)
} else {
xhttp.JsonBaseResponseCtx(r.Context(), w, resp)
}
}
}

View File

@@ -0,0 +1,67 @@
package share
import (
"context"
"errors"
"time"
"schisandra-album-cloud-microservices/app/auth/api/internal/svc"
"schisandra-album-cloud-microservices/app/auth/api/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListShareRecordLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListShareRecordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListShareRecordLogic {
return &ListShareRecordLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListShareRecordLogic) ListShareRecord(req *types.ShareRecordListRequest) (resp *types.ShareRecordListResponse, err error) {
uid, ok := l.ctx.Value("user_id").(string)
if !ok {
return nil, errors.New("user_id not found")
}
storageShare := l.svcCtx.DB.ScaStorageShare
storageAlbum := l.svcCtx.DB.ScaStorageAlbum
var recordList []types.ShareRecord
query := storageShare.
Select(storageShare.ID,
storageShare.ShareCode,
storageShare.VisitLimit,
storageShare.AccessPassword,
storageShare.ValidityPeriod,
storageShare.CreatedAt,
storageAlbum.CoverImage).
LeftJoin(storageAlbum, storageShare.AlbumID.EqCol(storageAlbum.ID)).
Where(storageShare.UserID.Eq(uid)).
Order(storageShare.CreatedAt.Desc())
if len(req.DateRange) == 2 {
startDate, errStart := time.Parse("2006-01-02", req.DateRange[0])
endDate, errEnd := time.Parse("2006-01-02", req.DateRange[1])
if errStart != nil || errEnd != nil {
return nil, errors.New("invalid date format")
}
// Ensure endDate is inclusive by adding 24 hours
endDate = endDate.AddDate(0, 0, 1)
query = query.Where(storageShare.CreatedAt.Between(startDate, endDate))
}
err = query.Scan(&recordList)
if err != nil {
return nil, err
}
resp = &types.ShareRecordListResponse{
Records: recordList,
}
return resp, nil
}

View File

@@ -0,0 +1,316 @@
package share
import (
"context"
"encoding/json"
"errors"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
"gorm.io/gorm"
"net/url"
"schisandra-album-cloud-microservices/app/auth/model/mysql/model"
"schisandra-album-cloud-microservices/common/constant"
"schisandra-album-cloud-microservices/common/encrypt"
storageConfig "schisandra-album-cloud-microservices/common/storage/config"
"time"
"schisandra-album-cloud-microservices/app/auth/api/internal/svc"
"schisandra-album-cloud-microservices/app/auth/api/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type QueryShareImageLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewQueryShareImageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryShareImageLogic {
return &QueryShareImageLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *QueryShareImageLogic) QueryShareImage(req *types.QueryShareImageRequest) (resp *types.QueryShareImageResponse, err error) {
uid, ok := l.ctx.Value("user_id").(string)
if !ok {
return nil, errors.New("user_id not found")
}
// 获取分享记录
cacheKey := constant.ImageSharePrefix + req.ShareCode
shareData, err := l.svcCtx.RedisClient.Get(l.ctx, cacheKey).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, errors.New("share code not found")
}
return nil, err
}
var storageShare model.ScaStorageShare
if err := json.Unmarshal([]byte(shareData), &storageShare); err != nil {
return nil, errors.New("unmarshal share data failed")
}
// 验证密码
if storageShare.AccessPassword != "" && storageShare.AccessPassword != req.AccessPassword {
return nil, errors.New("incorrect password")
}
// 检查分享是否过期
if storageShare.ExpireTime.Before(time.Now()) {
return nil, errors.New("share link has expired")
}
// 检查访问限制
if storageShare.VisitLimit > 0 {
err = l.incrementVisitCount(req.ShareCode, storageShare.VisitLimit)
if err != nil {
return nil, err
}
}
// 记录用户访问
err = l.recordUserVisit(storageShare.ID, uid)
if err != nil {
logx.Error("Failed to record user visit:", err)
return nil, err
}
// 生成缓存键(在验证通过后)
resultCacheKey := constant.ImageListPrefix + req.ShareCode + ":" + req.AccessPassword
// 尝试从缓存中获取结果
cachedResult, err := l.svcCtx.RedisClient.Get(l.ctx, resultCacheKey).Result()
if err == nil {
// 缓存命中,直接返回缓存结果
var cachedResponse types.QueryShareImageResponse
if err := json.Unmarshal([]byte(cachedResult), &cachedResponse); err == nil {
return &cachedResponse, nil
}
logx.Error("Failed to unmarshal cached result:", err)
} else if !errors.Is(err, redis.Nil) {
// 如果 Redis 查询出错(非缓存未命中),记录错误并继续回源查询
logx.Error("Failed to get cached result from Redis:", err)
}
// 缓存未命中,执行回源查询逻辑
resp, err = l.queryShareImageFromSource(&storageShare)
if err != nil {
return nil, err
}
// 将查询结果缓存到 Redis
respBytes, err := json.Marshal(resp)
if err != nil {
logx.Error("Failed to marshal response for caching:", err)
} else {
// 设置缓存,过期时间为 5 分钟
err = l.svcCtx.RedisClient.Set(l.ctx, resultCacheKey, respBytes, 5*time.Minute).Err()
if err != nil {
logx.Error("Failed to cache result in Redis:", err)
}
}
return resp, nil
}
func (l *QueryShareImageLogic) queryShareImageFromSource(storageShare *model.ScaStorageShare) (resp *types.QueryShareImageResponse, err error) {
// 查询相册图片列表
storageInfo := l.svcCtx.DB.ScaStorageInfo
storageThumb := l.svcCtx.DB.ScaStorageThumb
var storageInfoList []types.ShareFileInfoResult
err = storageInfo.Select(
storageInfo.ID,
storageInfo.FileName,
storageInfo.CreatedAt,
storageInfo.Provider,
storageInfo.Bucket,
storageInfo.Path,
storageThumb.ThumbPath,
storageThumb.ThumbW,
storageThumb.ThumbH,
storageThumb.ThumbSize).
LeftJoin(storageThumb, storageInfo.ThumbID.EqCol(storageThumb.ID)).
Where(
storageInfo.Type.Eq(constant.ImageTypeShared),
storageInfo.AlbumID.Eq(storageShare.AlbumID)).
Order(storageInfo.CreatedAt.Desc()).Scan(&storageInfoList)
if err != nil {
return nil, err
}
// 使用 errgroup 和 semaphore 并发处理图片信息
var ResultList []types.ShareImageListMeta
g, ctx := errgroup.WithContext(l.ctx)
sem := semaphore.NewWeighted(10) // 限制并发数为 10
for _, imgInfo := range storageInfoList {
imgInfo := imgInfo // 创建局部变量,避免闭包问题
if err := sem.Acquire(ctx, 1); err != nil {
return nil, err
}
g.Go(func() error {
defer sem.Release(1)
// 加载用户oss配置信息
cacheOssConfigKey := constant.UserOssConfigPrefix + storageShare.UserID + ":" + imgInfo.Provider
ossConfig, err := l.getOssConfigFromCacheOrDb(cacheOssConfigKey, storageShare.UserID, imgInfo.Provider)
if err != nil {
return err
}
service, err := l.svcCtx.StorageManager.GetStorage(storageShare.UserID, ossConfig)
if err != nil {
return errors.New("get storage failed")
}
ossURL, err := service.PresignedURL(ctx, ossConfig.BucketName, imgInfo.Path, 30*time.Minute)
if err != nil {
return errors.New("get presigned url failed")
}
reqParams := make(url.Values)
presignedURL, err := l.svcCtx.MinioClient.PresignedGetObject(ctx, constant.ThumbnailBucketName, imgInfo.ThumbPath, 30*time.Minute, reqParams)
if err != nil {
return errors.New("get presigned thumbnail url failed")
}
ResultList = append(ResultList, types.ShareImageListMeta{
ID: imgInfo.ID,
FileName: imgInfo.FileName,
ThumbH: imgInfo.ThumbH,
ThumbW: imgInfo.ThumbW,
ThumbSize: imgInfo.ThumbSize,
CreatedAt: imgInfo.CreatedAt.Format(constant.TimeFormat),
URL: ossURL,
Thumbnail: presignedURL.String(),
})
return nil
})
}
// 等待所有并发任务完成
if err := g.Wait(); err != nil {
return nil, err
}
return &types.QueryShareImageResponse{
List: ResultList}, nil
}
func (l *QueryShareImageLogic) recordUserVisit(shareID int64, userID string) error {
// 查询是否已经存在该用户对该分享的访问记录
var visitRecord model.ScaStorageShareVisit
scaStorageShareVisit := l.svcCtx.DB.ScaStorageShareVisit
_, err := scaStorageShareVisit.
Where(scaStorageShareVisit.ShareID.Eq(shareID), scaStorageShareVisit.UserID.Eq(userID)).
First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 如果记录不存在,创建新的访问记录
visitRecord = model.ScaStorageShareVisit{
UserID: userID,
ShareID: shareID,
Views: 1,
}
err = l.svcCtx.DB.ScaStorageShareVisit.Create(&visitRecord)
if err != nil {
return errors.New("failed to create visit record")
}
return nil
}
return errors.New("failed to query visit record")
}
// 如果记录存在,增加访问次数
info, err := scaStorageShareVisit.
Where(scaStorageShareVisit.UserID.Eq(userID), scaStorageShareVisit.ShareID.Eq(shareID)).
Update(scaStorageShareVisit.Views, scaStorageShareVisit.Views.Add(1))
if err != nil {
return errors.New("failed to update visit record")
}
if info.RowsAffected == 0 {
return errors.New("failed to update visit record")
}
return nil
}
func (l *QueryShareImageLogic) incrementVisitCount(shareCode string, limit int64) error {
// Redis 键值
cacheKey := constant.ImageShareVisitPrefix + shareCode
currentVisitCount, err := l.svcCtx.RedisClient.Get(l.ctx, cacheKey).Int64()
if err != nil && !errors.Is(err, redis.Nil) {
return err
}
// 如果访问次数超过限制,返回错误
if currentVisitCount >= limit {
return errors.New("access limit reached")
}
// 增加访问次数
err = l.svcCtx.RedisClient.Incr(l.ctx, cacheKey).Err()
if err != nil {
return err
}
return nil
}
// 提取解密操作为函数
func (l *QueryShareImageLogic) decryptConfig(config *model.ScaStorageConfig) (*storageConfig.StorageConfig, error) {
accessKey, err := encrypt.Decrypt(config.AccessKey, l.svcCtx.Config.Encrypt.Key)
if err != nil {
return nil, errors.New("decrypt access key failed")
}
secretKey, err := encrypt.Decrypt(config.SecretKey, l.svcCtx.Config.Encrypt.Key)
if err != nil {
return nil, errors.New("decrypt secret key failed")
}
return &storageConfig.StorageConfig{
Provider: config.Provider,
Endpoint: config.Endpoint,
AccessKey: accessKey,
SecretKey: secretKey,
BucketName: config.Bucket,
Region: config.Region,
}, nil
}
// 从缓存或数据库中获取 OSS 配置
func (l *QueryShareImageLogic) getOssConfigFromCacheOrDb(cacheKey, uid, provider string) (*storageConfig.StorageConfig, error) {
result, err := l.svcCtx.RedisClient.Get(l.ctx, cacheKey).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return nil, errors.New("get oss config failed")
}
var ossConfig *storageConfig.StorageConfig
if result != "" {
var redisOssConfig model.ScaStorageConfig
if err = json.Unmarshal([]byte(result), &redisOssConfig); err != nil {
return nil, errors.New("unmarshal oss config failed")
}
return l.decryptConfig(&redisOssConfig)
}
// 缓存未命中,从数据库中加载
scaOssConfig := l.svcCtx.DB.ScaStorageConfig
dbOssConfig, err := scaOssConfig.Where(scaOssConfig.UserID.Eq(uid), scaOssConfig.Provider.Eq(provider)).First()
if err != nil {
return nil, err
}
// 缓存数据库配置
ossConfig, err = l.decryptConfig(dbOssConfig)
if err != nil {
return nil, err
}
marshalData, err := json.Marshal(dbOssConfig)
if err != nil {
return nil, errors.New("marshal oss config failed")
}
err = l.svcCtx.RedisClient.Set(l.ctx, cacheKey, marshalData, 0).Err()
if err != nil {
return nil, errors.New("set oss config failed")
}
return ossConfig, nil
}

View File

@@ -0,0 +1,347 @@
package share
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/ccpwcn/kgo"
"github.com/minio/minio-go/v7"
"github.com/redis/go-redis/v9"
"github.com/zeromicro/go-zero/core/logx"
"golang.org/x/sync/errgroup"
"image"
"path"
"path/filepath"
"schisandra-album-cloud-microservices/app/auth/api/internal/svc"
"schisandra-album-cloud-microservices/app/auth/api/internal/types"
"schisandra-album-cloud-microservices/app/auth/model/mysql/model"
"schisandra-album-cloud-microservices/app/auth/model/mysql/query"
"schisandra-album-cloud-microservices/common/constant"
"schisandra-album-cloud-microservices/common/encrypt"
storageConfig "schisandra-album-cloud-microservices/common/storage/config"
"strconv"
"time"
)
type UploadShareImageLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUploadShareImageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadShareImageLogic {
return &UploadShareImageLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UploadShareImageLogic) UploadShareImage(req *types.ShareImageRequest) (resp string, err error) {
uid, ok := l.ctx.Value("user_id").(string)
if !ok {
return "", errors.New("user_id not found")
}
// 启动事务,确保插入操作的原子性
tx := l.svcCtx.DB.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback() // 如果有panic发生回滚事务
logx.Errorf("transaction rollback: %v", r)
}
}()
albumName := req.Title
if albumName == "" {
albumName = "快传照片"
}
// 创建一个相册
album := model.ScaStorageAlbum{
UserID: uid,
AlbumName: albumName,
CoverImage: req.Images[0].Thumbnail,
AlbumType: constant.AlbumTypeShared,
}
err = tx.ScaStorageAlbum.Create(&album)
if err != nil {
return "", err
}
var g errgroup.Group
// 为每张图片启动一个协程
for _, img := range req.Images {
img := img // 确保每个协程有独立的 img 参数副本
g.Go(func() error {
return l.uploadImageAndRecord(tx, uid, album, img, req)
})
}
// 等待所有任务完成并返回第一个错误
if err = g.Wait(); err != nil {
tx.Rollback()
return "", err
}
duration, err := strconv.Atoi(req.ExpireDate)
if err != nil {
return "", errors.New("invalid expire date")
}
expiryTime := l.GenerateExpiryTime(time.Now(), duration)
storageShare := model.ScaStorageShare{
UserID: uid,
AlbumID: album.ID,
ShareCode: kgo.SimpleUuid(),
Status: 0,
AccessPassword: req.AccessPassword,
VisitLimit: req.AccessLimit,
ValidityPeriod: int64(duration),
ExpireTime: expiryTime,
}
err = tx.ScaStorageShare.Create(&storageShare)
if err != nil {
tx.Rollback()
return "", err
}
// 缓存分享码
marshal, err := json.Marshal(storageShare)
if err != nil {
tx.Rollback()
return "", err
}
cacheKey := constant.ImageSharePrefix + storageShare.ShareCode
err = l.svcCtx.RedisClient.Set(l.ctx, cacheKey, marshal, time.Duration(duration)*time.Hour*24).Err()
if err != nil {
tx.Rollback()
return "", err
}
// 提交事务
if err = tx.Commit(); err != nil {
tx.Rollback()
logx.Errorf("Transaction commit failed: %v", err)
return "", err
}
return storageShare.ShareCode, nil
}
func (l *UploadShareImageLogic) uploadImageAndRecord(tx *query.QueryTx, uid string, album model.ScaStorageAlbum, img types.ShareImageMeta, req *types.ShareImageRequest) error {
// 上传缩略图到 Minio
thumbnail, err := base64.StdEncoding.DecodeString(img.Thumbnail)
if err != nil {
return fmt.Errorf("base64 decode failed: %v", err)
}
thumbObjectKey := path.Join(
uid,
time.Now().Format("2006/01"),
l.classifyFile(img.FileType),
fmt.Sprintf("%s_%s.jpg", time.Now().Format("20060102150405"), kgo.SimpleUuid()),
)
exists, err := l.svcCtx.MinioClient.BucketExists(l.ctx, constant.ThumbnailBucketName)
if err != nil || !exists {
err = l.svcCtx.MinioClient.MakeBucket(l.ctx, constant.ThumbnailBucketName, minio.MakeBucketOptions{Region: "us-east-1", ObjectLocking: true})
if err != nil {
logx.Errorf("Failed to create MinIO bucket: %v", err)
return err
}
}
_, err = l.svcCtx.MinioClient.PutObject(
l.ctx,
constant.ThumbnailBucketName,
thumbObjectKey,
bytes.NewReader(thumbnail),
int64(len(thumbnail)),
minio.PutObjectOptions{
ContentType: "image/jpeg",
},
)
if err != nil {
logx.Errorf("Failed to upload MinIO object: %v", err)
return err
}
// 记录缩略图
thumbRecord := model.ScaStorageThumb{
UserID: uid,
ThumbPath: thumbObjectKey,
ThumbW: img.ThumbW,
ThumbH: img.ThumbH,
ThumbSize: float64(len(thumbnail)),
}
err = tx.ScaStorageThumb.Create(&thumbRecord)
if err != nil {
return err
}
// 上传原始图片到用户的存储桶
originImage, err := base64.StdEncoding.DecodeString(img.OriginImage)
if err != nil {
return fmt.Errorf("base64 decode failed: %v", err)
}
originObjectKey := path.Join(
"share_space",
uid,
time.Now().Format("2006/01"),
fmt.Sprintf("%s_%s%s", img.FileName, kgo.SimpleUuid(), filepath.Ext(img.FileName)),
)
// 获取存储服务
ossConfig, err := l.getOssConfigFromCacheOrDb(constant.UserOssConfigPrefix+uid+":"+req.Provider, uid, req.Provider)
if err != nil {
return err
}
service, err := l.svcCtx.StorageManager.GetStorage(uid, ossConfig)
if err != nil {
return errors.New("get storage failed")
}
_, err = service.UploadFileSimple(l.ctx, ossConfig.BucketName, originObjectKey, bytes.NewReader(originImage), map[string]string{
"Content-Type": img.FileType,
})
if err != nil {
logx.Errorf("Failed to upload object to storage: %v", err)
return err
}
// 获取图片信息
width, height, size, err := l.GetImageInfo(img.OriginImage)
if err != nil {
return err
}
// 记录原始图片信息
imageRecord := model.ScaStorageInfo{
UserID: uid,
Provider: req.Provider,
Bucket: req.Bucket,
Path: originObjectKey,
FileName: img.FileName,
FileSize: strconv.Itoa(size),
FileType: img.FileType,
Width: float64(width),
Height: float64(height),
Type: constant.ImageTypeShared,
AlbumID: album.ID,
ThumbID: thumbRecord.ID,
}
err = tx.ScaStorageInfo.Create(&imageRecord)
if err != nil {
return err
}
return nil
}
func (l *UploadShareImageLogic) GetImageInfo(base64Str string) (width, height int, size int, err error) {
// 解码 Base64
data, err := base64.StdEncoding.DecodeString(base64Str)
if err != nil {
return 0, 0, 0, fmt.Errorf("base64 decode failed: %v", err)
}
// 获取图片大小
size = len(data)
// 解析图片宽高
reader := bytes.NewReader(data)
imgCfg, _, err := image.DecodeConfig(reader)
if err != nil {
return 0, 0, 0, fmt.Errorf("decode image config failed: %v", err)
}
return imgCfg.Width, imgCfg.Height, size, nil
}
// 提取解密操作为函数
func (l *UploadShareImageLogic) decryptConfig(config *model.ScaStorageConfig) (*storageConfig.StorageConfig, error) {
accessKey, err := encrypt.Decrypt(config.AccessKey, l.svcCtx.Config.Encrypt.Key)
if err != nil {
return nil, errors.New("decrypt access key failed")
}
secretKey, err := encrypt.Decrypt(config.SecretKey, l.svcCtx.Config.Encrypt.Key)
if err != nil {
return nil, errors.New("decrypt secret key failed")
}
return &storageConfig.StorageConfig{
Provider: config.Provider,
Endpoint: config.Endpoint,
AccessKey: accessKey,
SecretKey: secretKey,
BucketName: config.Bucket,
Region: config.Region,
}, nil
}
// 从缓存或数据库中获取 OSS 配置
func (l *UploadShareImageLogic) getOssConfigFromCacheOrDb(cacheKey, uid, provider string) (*storageConfig.StorageConfig, error) {
result, err := l.svcCtx.RedisClient.Get(l.ctx, cacheKey).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return nil, errors.New("get oss config failed")
}
var ossConfig *storageConfig.StorageConfig
if result != "" {
var redisOssConfig model.ScaStorageConfig
if err = json.Unmarshal([]byte(result), &redisOssConfig); err != nil {
return nil, errors.New("unmarshal oss config failed")
}
return l.decryptConfig(&redisOssConfig)
}
// 缓存未命中,从数据库中加载
scaOssConfig := l.svcCtx.DB.ScaStorageConfig
dbOssConfig, err := scaOssConfig.Where(scaOssConfig.UserID.Eq(uid), scaOssConfig.Provider.Eq(provider)).First()
if err != nil {
return nil, err
}
// 缓存数据库配置
ossConfig, err = l.decryptConfig(dbOssConfig)
if err != nil {
return nil, err
}
marshalData, err := json.Marshal(dbOssConfig)
if err != nil {
return nil, errors.New("marshal oss config failed")
}
err = l.svcCtx.RedisClient.Set(l.ctx, cacheKey, marshalData, 0).Err()
if err != nil {
return nil, errors.New("set oss config failed")
}
return ossConfig, nil
}
// GenerateExpiryTime 函数接受当前时间和有效期(天为单位),返回过期时间
func (l *UploadShareImageLogic) GenerateExpiryTime(currentTime time.Time, durationInDays int) time.Time {
// 创建一个持续时间对象
duration := time.Duration(durationInDays) * 24 * time.Hour
// 将当前时间加上持续时间,得到过期时间
expiryTime := currentTime.Add(duration)
return expiryTime
}
func (l *UploadShareImageLogic) classifyFile(mimeType string) string {
// 使用map存储MIME类型及其对应的分类
typeMap := map[string]string{
"image/jpeg": "image",
"image/png": "image",
"image/gif": "gif",
"image/bmp": "image",
"image/tiff": "image",
"image/webp": "image",
"video/mp4": "video",
"video/avi": "video",
"video/mpeg": "video",
"video/quicktime": "video",
"video/x-msvideo": "video",
"video/x-flv": "video",
"video/x-matroska": "video",
}
// 根据MIME类型从map中获取分类
if classification, exists := typeMap[mimeType]; exists {
return classification
}
return "other"
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"schisandra-album-cloud-microservices/app/auth/model/mysql/model"
"schisandra-album-cloud-microservices/common/constant"
"schisandra-album-cloud-microservices/app/auth/api/internal/svc"
"schisandra-album-cloud-microservices/app/auth/api/internal/types"
@@ -33,7 +34,7 @@ func (l *CreateAlbumLogic) CreateAlbum(req *types.AlbumCreateRequest) (resp *typ
storageAlbum := &model.ScaStorageAlbum{
UserID: uid,
AlbumName: req.Name,
AlbumType: "0",
AlbumType: constant.AlbumTypeMine,
}
err = l.svcCtx.DB.ScaStorageAlbum.Create(storageAlbum)
if err != nil {

View File

@@ -68,6 +68,7 @@ func (l *GetAlbumDetailLogic) GetAlbumDetail(req *types.AlbumDetailListRequest)
storageInfo.FileName,
storageInfo.CreatedAt,
storageThumb.ThumbPath,
storageInfo.Path,
storageThumb.ThumbW,
storageThumb.ThumbH,
storageThumb.ThumbSize).
@@ -86,17 +87,17 @@ func (l *GetAlbumDetailLogic) GetAlbumDetail(req *types.AlbumDetailListRequest)
return &types.AlbumDetailListResponse{}, nil
}
//// 加载用户oss配置信息
//cacheOssConfigKey := constant.UserOssConfigPrefix + uid + ":" + req.Provider
//ossConfig, err := l.getOssConfigFromCacheOrDb(cacheOssConfigKey, uid, req.Provider)
//if err != nil {
// return nil, err
//}
//
//service, err := l.svcCtx.StorageManager.GetStorage(uid, ossConfig)
//if err != nil {
// return nil, errors.New("get storage failed")
//}
// 加载用户oss配置信息
cacheOssConfigKey := constant.UserOssConfigPrefix + uid + ":" + req.Provider
ossConfig, err := l.getOssConfigFromCacheOrDb(cacheOssConfigKey, uid, req.Provider)
if err != nil {
return nil, err
}
service, err := l.svcCtx.StorageManager.GetStorage(uid, ossConfig)
if err != nil {
return nil, errors.New("get storage failed")
}
// 按日期进行分组
var wg sync.WaitGroup
@@ -114,6 +115,11 @@ func (l *GetAlbumDetailLogic) GetAlbumDetail(req *types.AlbumDetailListRequest)
logx.Error(err)
return
}
url, err := service.PresignedURL(l.ctx, ossConfig.BucketName, dbFileInfo.Path, time.Hour*24*7)
if err != nil {
logx.Error(err)
return
}
// 使用 Load 或 Store 确保原子操作
value, _ := groupedImages.LoadOrStore(date, []types.ImageMeta{})
images := value.([]types.ImageMeta)
@@ -121,7 +127,8 @@ func (l *GetAlbumDetailLogic) GetAlbumDetail(req *types.AlbumDetailListRequest)
images = append(images, types.ImageMeta{
ID: dbFileInfo.ID,
FileName: dbFileInfo.FileName,
URL: presignedUrl.String(),
Thumbnail: presignedUrl.String(),
URL: url,
Width: dbFileInfo.ThumbW,
Height: dbFileInfo.ThumbH,
CreatedAt: dbFileInfo.CreatedAt.Format("2006-01-02 15:04:05"),

View File

@@ -3,6 +3,7 @@ package storage
import (
"context"
"errors"
"gorm.io/gen"
"gorm.io/gen/field"
"schisandra-album-cloud-microservices/app/auth/api/internal/svc"
@@ -37,7 +38,15 @@ func (l *GetAlbumListLogic) GetAlbumList(req *types.AlbumListRequest) (resp *typ
} else {
orderConditions = append(orderConditions, storageAlbum.AlbumName.Desc())
}
albums, err := storageAlbum.Where(storageAlbum.UserID.Eq(uid), storageAlbum.AlbumType.Eq(req.Type)).Order(orderConditions...).Find()
var typeConditions []gen.Condition
if req.Type != -1 {
// 获取全部相册
typeConditions = append(typeConditions, storageAlbum.AlbumType.Eq(req.Type))
typeConditions = append(typeConditions, storageAlbum.UserID.Eq(uid))
}
albums, err := storageAlbum.Where(
typeConditions...).
Order(orderConditions...).Find()
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,80 @@
package storage
import (
"context"
"errors"
"schisandra-album-cloud-microservices/app/auth/api/internal/svc"
"schisandra-album-cloud-microservices/app/auth/api/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetUserStorageListLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetUserStorageListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUserStorageListLogic {
return &GetUserStorageListLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// providerNameMap 存储商映射表
var providerNameMap = map[string]string{
"ali": "阿里云OSS",
"tencent": "腾讯云COS",
"aws": "Amazon S3",
"qiniu": "七牛云",
"huawei": "华为云OBS",
}
func (l *GetUserStorageListLogic) GetUserStorageList() (resp *types.StorageListResponse, err error) {
uid, ok := l.ctx.Value("user_id").(string)
if !ok {
return nil, errors.New("user_id not found")
}
storageConfig := l.svcCtx.DB.ScaStorageConfig
storageConfigs, err := storageConfig.Select(
storageConfig.Provider,
storageConfig.Bucket).
Where(
storageConfig.UserID.Eq(uid)).Find()
if err != nil {
return nil, err
}
// 使用 map 组织数据
providerMap := make(map[string][]types.StorageMeta)
for _, config := range storageConfigs {
providerMap[config.Provider] = append(providerMap[config.Provider], types.StorageMeta{
Value: config.Bucket,
Name: config.Bucket,
})
}
// 组装返回结构
var records []types.StroageNode
for provider, buckets := range providerMap {
records = append(records, types.StroageNode{
Value: provider,
Name: l.getProviderName(provider),
Children: buckets,
})
}
// 返回数据
return &types.StorageListResponse{
Records: records,
}, nil
}
// getProviderName 获取存储商的中文名称
func (l *GetUserStorageListLogic) getProviderName(provider string) string {
if name, exists := providerNameMap[provider]; exists {
return name
}
return provider
}

View File

@@ -288,6 +288,7 @@ func (l *UploadFileLogic) uploadFileToOSS(uid string, header *multipart.FileHead
}
objectKey := path.Join(
"image_space",
uid,
time.Now().Format("2006/01"), // 按年/月划分目录
l.classifyFile(result.FileType, result.IsScreenshot),

View File

@@ -3,7 +3,9 @@ package middleware
import (
"github.com/casbin/casbin/v2"
"net/http"
"schisandra-album-cloud-microservices/common/middleware"
"schisandra-album-cloud-microservices/common/constant"
"schisandra-album-cloud-microservices/common/errors"
"schisandra-album-cloud-microservices/common/xhttp"
)
type CasbinVerifyMiddleware struct {
@@ -18,7 +20,12 @@ func NewCasbinVerifyMiddleware(casbin *casbin.SyncedCachedEnforcer) *CasbinVerif
func (m *CasbinVerifyMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
middleware.CasbinMiddleware(w, r, m.casbin)
userId := r.Header.Get(constant.UID_HEADER_KEY)
correct, err := m.casbin.Enforce(userId, r.URL.Path, r.Method)
if err != nil || !correct {
xhttp.JsonBaseResponseCtx(r.Context(), w, errors.New(http.StatusNotFound, "not found"))
return
}
next(w, r)
}
}

View File

@@ -3,7 +3,10 @@ package middleware
import (
"github.com/redis/go-redis/v9"
"net/http"
"schisandra-album-cloud-microservices/common/middleware"
"schisandra-album-cloud-microservices/common/constant"
"schisandra-album-cloud-microservices/common/errors"
"schisandra-album-cloud-microservices/common/xhttp"
"time"
)
type NonceMiddleware struct {
@@ -18,7 +21,25 @@ func NewNonceMiddleware(redisClient *redis.Client) *NonceMiddleware {
func (m *NonceMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
middleware.NonceMiddleware(w, r, m.RedisClient)
nonce := r.Header.Get("X-Nonce")
if nonce == "" {
xhttp.JsonBaseResponseCtx(r.Context(), w, errors.New(http.StatusBadRequest, "bad request!"))
return
}
if len(nonce) != 32 {
xhttp.JsonBaseResponseCtx(r.Context(), w, errors.New(http.StatusBadRequest, "bad request!"))
return
}
result := m.RedisClient.Get(r.Context(), constant.SystemApiNoncePrefix+nonce).Val()
if result != "" {
xhttp.JsonBaseResponseCtx(r.Context(), w, errors.New(http.StatusBadRequest, "bad request!"))
return
}
err := m.RedisClient.Set(r.Context(), constant.SystemApiNoncePrefix+nonce, nonce, time.Minute*1).Err()
if err != nil {
xhttp.JsonBaseResponseCtx(r.Context(), w, errors.New(http.StatusInternalServerError, "internal server error!"))
return
}
next(w, r)
}
}

View File

@@ -54,3 +54,12 @@ type ThingImageList struct {
ThumbPath string `json:"thumb_path"`
Path string `json:"path"`
}
type ShareImageInfo struct {
Title string `json:"title"`
ExpireDate string `json:"expire_date"`
AccessLimit int64 `json:"access_limit"`
AccessPassword string `json:"access_password"`
Provider string `json:"provider"`
Bucket string `json:"bucket"`
}

View File

@@ -0,0 +1,16 @@
package types
import "time"
type ShareFileInfoResult struct {
ID int64 `json:"id"`
FileName string `json:"file_name"`
ThumbPath string `json:"thumb_path"`
ThumbW float64 `json:"thumb_w"`
ThumbH float64 `json:"thumb_h"`
ThumbSize float64 `json:"thumb_size"`
CreatedAt time.Time `json:"created_at"`
Path string `json:"path"`
Provider string `json:"provider"`
Bucket string `json:"bucket"`
}

View File

@@ -15,7 +15,7 @@ type Album struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
Type string `json:"type"`
Type int64 `json:"type"`
CoverImage string `json:"cover_image"`
}
@@ -42,8 +42,8 @@ type AlbumDetailListResponse struct {
}
type AlbumListRequest struct {
Type string `json:"type"`
Sort bool `json:"sort"`
Type int64 `json:"type,omitempty"`
Sort bool `json:"sort"`
}
type AlbumListResponse struct {
@@ -254,6 +254,15 @@ type PhoneLoginRequest struct {
AutoLogin bool `json:"auto_login"`
}
type QueryShareImageRequest struct {
ShareCode string `json:"share_code"`
AccessPassword string `json:"access_password,omitempty"`
}
type QueryShareImageResponse struct {
List []ShareImageListMeta `json:"list"`
}
type RecentListResponse struct {
Records []AllImageDetail `json:"records"`
}
@@ -306,6 +315,55 @@ type RotateCaptchaResponse struct {
Thumb string `json:"thumb"`
}
type ShareImageListMeta struct {
ID int64 `json:"id"`
FileName string `json:"file_name"`
URL string `json:"url"`
Thumbnail string `json:"thumbnail"`
ThumbW float64 `json:"thumb_w"`
ThumbH float64 `json:"thumb_h"`
ThumbSize float64 `json:"thumb_size"`
CreatedAt string `json:"created_at"`
}
type ShareImageMeta struct {
FileName string `json:"file_name"`
OriginImage string `json:"origin_image"`
FileType string `json:"file_type"`
Thumbnail string `json:"thumbnail"`
ThumbW float64 `json:"thumb_w"`
ThumbH float64 `json:"thumb_h"`
ThumbSize int64 `json:"thumb_size"`
}
type ShareImageRequest struct {
Title string `json:"title,omitempty"`
ExpireDate string `json:"expire_date"`
AccessLimit int64 `json:"access_limit,omitempty"`
AccessPassword string `json:"access_password,omitempty"`
Provider string `json:"provider"`
Bucket string `json:"bucket"`
Images []ShareImageMeta `json:"images"`
}
type ShareRecord struct {
ID int64 `json:"id"`
CoverImage string `json:"cover_image"`
CreatedAt string `json:"created_at"`
ShareCode string `json:"share_code"`
VisitLimit int64 `json:"visit_limit"`
AccessPassword string `json:"access_password"`
ValidityPeriod int64 `json:"validity_period"`
}
type ShareRecordListRequest struct {
DateRange []string `json:"date_range"`
}
type ShareRecordListResponse struct {
Records []ShareRecord `json:"records"`
}
type SingleImageRequest struct {
ID int64 `json:"id"`
}
@@ -335,6 +393,21 @@ type StorageConfigRequest struct {
Region string `json:"region"`
}
type StorageListResponse struct {
Records []StroageNode `json:"records"`
}
type StorageMeta struct {
Name string `json:"name"`
Value string `json:"value"`
}
type StroageNode struct {
Value string `json:"value"`
Name string `json:"name"`
Children []StorageMeta `json:"children"`
}
type ThingDetailListRequest struct {
TagName string `json:"tag_name"`
Provider string `json:"provider"`