feat: add file upload workflow
This commit is contained in:
@@ -17,8 +17,10 @@ type Config struct {
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
Endpoint string
|
||||
Bucket string
|
||||
Endpoint string
|
||||
Bucket string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
@@ -31,8 +33,10 @@ func Load() Config {
|
||||
RedisDB: getEnvInt("REDIS_DB", 0),
|
||||
JWTSecret: getEnv("JWT_SECRET", "change-me"),
|
||||
Storage: StorageConfig{
|
||||
Endpoint: getEnv("STORAGE_ENDPOINT", "http://127.0.0.1:9000"),
|
||||
Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"),
|
||||
Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:9000"),
|
||||
Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"),
|
||||
AccessKeyID: getEnv("STORAGE_ACCESS_KEY_ID", "minioadmin"),
|
||||
SecretAccessKey: getEnv("STORAGE_SECRET_ACCESS_KEY", "minioadmin"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package file
|
||||
|
||||
type UploadDTO struct {
|
||||
ObjectKey string `json:"object_key"`
|
||||
URL string `json:"url"`
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"content_type"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"hfb_sys/backend/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
storage *Storage
|
||||
}
|
||||
|
||||
func NewHandler(service *Service, storage *Storage) *Handler {
|
||||
return &Handler{service: service, storage: storage}
|
||||
}
|
||||
|
||||
func (h *Handler) Upload(c *gin.Context) {
|
||||
header, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "请选择要上传的文件")
|
||||
return
|
||||
}
|
||||
reader, err := header.Open()
|
||||
if err != nil {
|
||||
response.BadRequest(c, "文件读取失败")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = reader.Close()
|
||||
}()
|
||||
item, err := h.service.Upload(uploadRequest{
|
||||
Context: c.Request.Context(),
|
||||
Scene: c.PostForm("scene"),
|
||||
Header: header,
|
||||
Reader: reader,
|
||||
ContentType: header.Header.Get("Content-Type"),
|
||||
})
|
||||
if err != nil {
|
||||
writeFileError(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) Object(c *gin.Context) {
|
||||
if h.storage == nil {
|
||||
response.ServiceUnavailable(c, "文件存储未连接")
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(c.Query("key"))
|
||||
if key == "" || strings.Contains(key, "..") {
|
||||
response.BadRequest(c, "文件 key 不正确")
|
||||
return
|
||||
}
|
||||
object, err := h.storage.Get(c.Request.Context(), key)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = object.Reader.Close()
|
||||
}()
|
||||
contentType := object.ContentType
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
c.Header("Content-Type", contentType)
|
||||
c.Header("Cache-Control", "private, max-age=300")
|
||||
c.DataFromReader(http.StatusOK, object.Size, contentType, object.Reader, nil)
|
||||
}
|
||||
|
||||
func writeFileError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrDependencyUnavailable):
|
||||
response.ServiceUnavailable(c, "文件存储未连接")
|
||||
case errors.Is(err, ErrInvalidFile):
|
||||
response.BadRequest(c, "文件不符合规则,仅支持 10MB 内的 JPG、PNG、WebP 或 PDF")
|
||||
default:
|
||||
response.Error(c, http.StatusInternalServerError, "internal_error", "文件服务暂时不可用")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrInvalidFile = errors.New("invalid file")
|
||||
)
|
||||
|
||||
const maxUploadSize = 10 * 1024 * 1024
|
||||
|
||||
var allowedContentTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/webp": true,
|
||||
"application/pdf": true,
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
storage *Storage
|
||||
}
|
||||
|
||||
func NewService(storage *Storage) *Service {
|
||||
return &Service{storage: storage}
|
||||
}
|
||||
|
||||
func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
||||
if s.storage == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if req.Header == nil || req.Reader == nil || req.Header.Size <= 0 || req.Header.Size > maxUploadSize {
|
||||
return nil, ErrInvalidFile
|
||||
}
|
||||
contentType := req.ContentType
|
||||
if contentType == "" {
|
||||
contentType = req.Header.Header.Get("Content-Type")
|
||||
}
|
||||
if !allowedContentTypes[contentType] {
|
||||
return nil, ErrInvalidFile
|
||||
}
|
||||
scene := normalizeScene(req.Scene)
|
||||
key, err := s.storage.Put(req.Context, scene, req.Header, req.Reader, contentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UploadDTO{
|
||||
ObjectKey: key,
|
||||
URL: "/api/files/object?key=" + key,
|
||||
Filename: req.Header.Filename,
|
||||
ContentType: contentType,
|
||||
Size: req.Header.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeScene(scene string) string {
|
||||
scene = strings.TrimSpace(strings.ToLower(scene))
|
||||
switch scene {
|
||||
case "listing", "handoff", "dispute", "realname", "avatar":
|
||||
return scene
|
||||
default:
|
||||
return "misc"
|
||||
}
|
||||
}
|
||||
|
||||
type uploadRequest struct {
|
||||
Context context.Context
|
||||
Scene string
|
||||
Header *multipart.FileHeader
|
||||
Reader multipart.File
|
||||
ContentType string
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/config"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
type Object struct {
|
||||
Reader io.ReadCloser
|
||||
ContentType string
|
||||
Size int64
|
||||
}
|
||||
|
||||
func NewStorage(cfg config.StorageConfig) (*Storage, error) {
|
||||
endpoint, secure, err := normalizeEndpoint(cfg.Endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKeyID, cfg.SecretAccessKey, ""),
|
||||
Secure: secure,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storage := &Storage{client: client, bucket: cfg.Bucket}
|
||||
if err := storage.ensureBucket(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
func (s *Storage) Put(ctx context.Context, scene string, header *multipart.FileHeader, reader io.Reader, contentType string) (string, error) {
|
||||
key, err := newObjectKey(scene, header.Filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = s.client.PutObject(ctx, s.bucket, key, reader, header.Size, minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
UserMetadata: map[string]string{
|
||||
"original-filename": header.Filename,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (s *Storage) Get(ctx context.Context, key string) (*Object, error) {
|
||||
object, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := object.Stat()
|
||||
if err != nil {
|
||||
_ = object.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Object{
|
||||
Reader: object,
|
||||
ContentType: info.ContentType,
|
||||
Size: info.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Storage) ensureBucket(ctx context.Context) error {
|
||||
exists, err := s.client.BucketExists(ctx, s.bucket)
|
||||
if err != nil {
|
||||
errResp := minio.ToErrorResponse(err)
|
||||
if errResp.Code != "NoSuchBucket" && !strings.Contains(strings.ToLower(err.Error()), "bucket does not exist") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
return s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{})
|
||||
}
|
||||
|
||||
func normalizeEndpoint(raw string) (string, bool, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if parsed.Scheme == "" {
|
||||
return raw, false, nil
|
||||
}
|
||||
return parsed.Host, parsed.Scheme == "https", nil
|
||||
}
|
||||
|
||||
func newObjectKey(scene string, filename string) (string, error) {
|
||||
if scene == "" {
|
||||
scene = "misc"
|
||||
}
|
||||
scene = strings.ToLower(scene)
|
||||
now := time.Now()
|
||||
token := make([]byte, 12)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ext := strings.ToLower(path.Ext(filename))
|
||||
return fmt.Sprintf("%s/%04d/%02d/%02d/%s%s", scene, now.Year(), now.Month(), now.Day(), hex.EncodeToString(token), ext), nil
|
||||
}
|
||||
@@ -3,45 +3,47 @@ package listing
|
||||
import "time"
|
||||
|
||||
type ListingDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
AccountID uint64 `json:"account_id"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||
OwnerNickname string `json:"owner_nickname,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
GameName string `json:"game_name"`
|
||||
ServerRegion string `json:"server_region"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RankLevel string `json:"rank_level"`
|
||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
||||
PriceHourly float64 `json:"price_hourly"`
|
||||
PriceDaily float64 `json:"price_daily"`
|
||||
PriceWeekly float64 `json:"price_weekly"`
|
||||
DepositAmount float64 `json:"deposit_amount"`
|
||||
MinRentHours int `json:"min_rent_hours"`
|
||||
MaxRentHours int `json:"max_rent_hours"`
|
||||
Status string `json:"status"`
|
||||
ReviewStatus string `json:"review_status"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `json:"id"`
|
||||
AccountID uint64 `json:"account_id"`
|
||||
OwnerID uint64 `json:"owner_id"`
|
||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||
OwnerNickname string `json:"owner_nickname,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
GameName string `json:"game_name"`
|
||||
ServerRegion string `json:"server_region"`
|
||||
LoginPlatform string `json:"login_platform"`
|
||||
RankLevel string `json:"rank_level"`
|
||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
||||
ScreenshotURLS []string `json:"screenshot_urls"`
|
||||
PriceHourly float64 `json:"price_hourly"`
|
||||
PriceDaily float64 `json:"price_daily"`
|
||||
PriceWeekly float64 `json:"price_weekly"`
|
||||
DepositAmount float64 `json:"deposit_amount"`
|
||||
MinRentHours int `json:"min_rent_hours"`
|
||||
MaxRentHours int `json:"max_rent_hours"`
|
||||
Status string `json:"status"`
|
||||
ReviewStatus string `json:"review_status"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ServerRegion string `json:"server_region" binding:"required"`
|
||||
LoginPlatform string `json:"login_platform" binding:"required"`
|
||||
RankLevel string `json:"rank_level"`
|
||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
||||
PriceHourly float64 `json:"price_hourly" binding:"required"`
|
||||
PriceDaily float64 `json:"price_daily"`
|
||||
PriceWeekly float64 `json:"price_weekly"`
|
||||
DepositAmount float64 `json:"deposit_amount" binding:"required"`
|
||||
MinRentHours int `json:"min_rent_hours" binding:"required"`
|
||||
MaxRentHours int `json:"max_rent_hours" binding:"required"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ServerRegion string `json:"server_region" binding:"required"`
|
||||
LoginPlatform string `json:"login_platform" binding:"required"`
|
||||
RankLevel string `json:"rank_level"`
|
||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
||||
ScreenshotURLS []string `json:"screenshot_urls"`
|
||||
PriceHourly float64 `json:"price_hourly" binding:"required"`
|
||||
PriceDaily float64 `json:"price_daily"`
|
||||
PriceWeekly float64 `json:"price_weekly"`
|
||||
DepositAmount float64 `json:"deposit_amount" binding:"required"`
|
||||
MinRentHours int `json:"min_rent_hours" binding:"required"`
|
||||
MaxRentHours int `json:"max_rent_hours" binding:"required"`
|
||||
}
|
||||
|
||||
type UpdateRequest = CreateRequest
|
||||
|
||||
@@ -3,6 +3,7 @@ package listing
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
@@ -24,16 +25,21 @@ func NewRepository(db *gorm.DB) *Repository {
|
||||
func (r *Repository) Create(ownerID uint64, req CreateRequest) (*ListingDTO, error) {
|
||||
var dto *ListingDTO
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
screenshots, err := marshalScreenshots(req.ScreenshotURLS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account := model.GameAccount{
|
||||
OwnerID: ownerID,
|
||||
GameName: "delta_force",
|
||||
ServerRegion: req.ServerRegion,
|
||||
LoginPlatform: req.LoginPlatform,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
RankLevel: req.RankLevel,
|
||||
HafCoinAmount: req.HafCoinAmount,
|
||||
Status: "draft",
|
||||
OwnerID: ownerID,
|
||||
GameName: "delta_force",
|
||||
ServerRegion: req.ServerRegion,
|
||||
LoginPlatform: req.LoginPlatform,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
RankLevel: req.RankLevel,
|
||||
HafCoinAmount: req.HafCoinAmount,
|
||||
ScreenshotURLS: screenshots,
|
||||
Status: "draft",
|
||||
}
|
||||
if err := tx.Create(&account).Error; err != nil {
|
||||
return err
|
||||
@@ -76,6 +82,11 @@ func (r *Repository) Update(ownerID uint64, listingID uint64, req UpdateRequest)
|
||||
account.LoginPlatform = req.LoginPlatform
|
||||
account.RankLevel = req.RankLevel
|
||||
account.HafCoinAmount = req.HafCoinAmount
|
||||
screenshots, err := marshalScreenshots(req.ScreenshotURLS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
account.ScreenshotURLS = screenshots
|
||||
if err := tx.Save(account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -386,7 +397,7 @@ func (r *Repository) findDTO(where string, args ...any) (*ListingDTO, error) {
|
||||
func (r *Repository) baseQuery() *gorm.DB {
|
||||
return r.db.Table("rental_listings AS l").
|
||||
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
|
||||
a.haf_coin_amount, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname`).
|
||||
a.haf_coin_amount, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname`).
|
||||
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
|
||||
Joins("LEFT JOIN users AS u ON u.id = l.owner_id")
|
||||
}
|
||||
@@ -405,15 +416,16 @@ func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.
|
||||
|
||||
type listingRow struct {
|
||||
model.RentalListing
|
||||
Title string
|
||||
OwnerPhone string
|
||||
OwnerNickname string
|
||||
Description string
|
||||
GameName string
|
||||
ServerRegion string
|
||||
LoginPlatform string
|
||||
RankLevel string
|
||||
HafCoinAmount int64
|
||||
Title string
|
||||
OwnerPhone string
|
||||
OwnerNickname string
|
||||
Description string
|
||||
GameName string
|
||||
ServerRegion string
|
||||
LoginPlatform string
|
||||
RankLevel string
|
||||
HafCoinAmount int64
|
||||
ScreenshotURLS datatypes.JSON
|
||||
}
|
||||
|
||||
func rowsToDTO(rows []listingRow) []ListingDTO {
|
||||
@@ -426,60 +438,97 @@ func rowsToDTO(rows []listingRow) []ListingDTO {
|
||||
|
||||
func (row listingRow) toDTO() ListingDTO {
|
||||
return ListingDTO{
|
||||
ID: row.ID,
|
||||
AccountID: row.AccountID,
|
||||
OwnerID: row.OwnerID,
|
||||
OwnerPhone: row.OwnerPhone,
|
||||
OwnerNickname: row.OwnerNickname,
|
||||
Title: row.Title,
|
||||
Description: row.Description,
|
||||
GameName: row.GameName,
|
||||
ServerRegion: row.ServerRegion,
|
||||
LoginPlatform: row.LoginPlatform,
|
||||
RankLevel: row.RankLevel,
|
||||
HafCoinAmount: row.HafCoinAmount,
|
||||
PriceHourly: row.PriceHourly,
|
||||
PriceDaily: row.PriceDaily,
|
||||
PriceWeekly: row.PriceWeekly,
|
||||
DepositAmount: row.DepositAmount,
|
||||
MinRentHours: row.MinRentHours,
|
||||
MaxRentHours: row.MaxRentHours,
|
||||
Status: row.Status,
|
||||
ReviewStatus: row.ReviewStatus,
|
||||
ReviewReason: row.ReviewReason,
|
||||
PublishedAt: row.PublishedAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ID: row.ID,
|
||||
AccountID: row.AccountID,
|
||||
OwnerID: row.OwnerID,
|
||||
OwnerPhone: row.OwnerPhone,
|
||||
OwnerNickname: row.OwnerNickname,
|
||||
Title: row.Title,
|
||||
Description: row.Description,
|
||||
GameName: row.GameName,
|
||||
ServerRegion: row.ServerRegion,
|
||||
LoginPlatform: row.LoginPlatform,
|
||||
RankLevel: row.RankLevel,
|
||||
HafCoinAmount: row.HafCoinAmount,
|
||||
ScreenshotURLS: decodeScreenshots(row.ScreenshotURLS),
|
||||
PriceHourly: row.PriceHourly,
|
||||
PriceDaily: row.PriceDaily,
|
||||
PriceWeekly: row.PriceWeekly,
|
||||
DepositAmount: row.DepositAmount,
|
||||
MinRentHours: row.MinRentHours,
|
||||
MaxRentHours: row.MaxRentHours,
|
||||
Status: row.Status,
|
||||
ReviewStatus: row.ReviewStatus,
|
||||
ReviewReason: row.ReviewReason,
|
||||
PublishedAt: row.PublishedAt,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
|
||||
return &ListingDTO{
|
||||
ID: listing.ID,
|
||||
AccountID: account.ID,
|
||||
OwnerID: listing.OwnerID,
|
||||
Title: account.Title,
|
||||
Description: account.Description,
|
||||
GameName: account.GameName,
|
||||
ServerRegion: account.ServerRegion,
|
||||
LoginPlatform: account.LoginPlatform,
|
||||
RankLevel: account.RankLevel,
|
||||
HafCoinAmount: account.HafCoinAmount,
|
||||
PriceHourly: listing.PriceHourly,
|
||||
PriceDaily: listing.PriceDaily,
|
||||
PriceWeekly: listing.PriceWeekly,
|
||||
DepositAmount: listing.DepositAmount,
|
||||
MinRentHours: listing.MinRentHours,
|
||||
MaxRentHours: listing.MaxRentHours,
|
||||
Status: listing.Status,
|
||||
ReviewStatus: listing.ReviewStatus,
|
||||
ReviewReason: listing.ReviewReason,
|
||||
PublishedAt: listing.PublishedAt,
|
||||
CreatedAt: listing.CreatedAt,
|
||||
UpdatedAt: listing.UpdatedAt,
|
||||
ID: listing.ID,
|
||||
AccountID: account.ID,
|
||||
OwnerID: listing.OwnerID,
|
||||
Title: account.Title,
|
||||
Description: account.Description,
|
||||
GameName: account.GameName,
|
||||
ServerRegion: account.ServerRegion,
|
||||
LoginPlatform: account.LoginPlatform,
|
||||
RankLevel: account.RankLevel,
|
||||
HafCoinAmount: account.HafCoinAmount,
|
||||
ScreenshotURLS: decodeScreenshots(account.ScreenshotURLS),
|
||||
PriceHourly: listing.PriceHourly,
|
||||
PriceDaily: listing.PriceDaily,
|
||||
PriceWeekly: listing.PriceWeekly,
|
||||
DepositAmount: listing.DepositAmount,
|
||||
MinRentHours: listing.MinRentHours,
|
||||
MaxRentHours: listing.MaxRentHours,
|
||||
Status: listing.Status,
|
||||
ReviewStatus: listing.ReviewStatus,
|
||||
ReviewReason: listing.ReviewReason,
|
||||
PublishedAt: listing.PublishedAt,
|
||||
CreatedAt: listing.CreatedAt,
|
||||
UpdatedAt: listing.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func marshalScreenshots(urls []string) (datatypes.JSON, error) {
|
||||
cleaned := make([]string, 0, len(urls))
|
||||
seen := make(map[string]struct{}, len(urls))
|
||||
for _, url := range urls {
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[url]; ok {
|
||||
continue
|
||||
}
|
||||
seen[url] = struct{}{}
|
||||
cleaned = append(cleaned, url)
|
||||
if len(cleaned) >= 12 {
|
||||
break
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(cleaned)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return datatypes.JSON(raw), nil
|
||||
}
|
||||
|
||||
func decodeScreenshots(raw datatypes.JSON) []string {
|
||||
if len(raw) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
var urls []string
|
||||
if err := json.Unmarshal(raw, &urls); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizType string, bizID uint64, meta AuditMeta, detail map[string]any) error {
|
||||
raw, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"hfb_sys/backend/internal/modules/adminuser"
|
||||
"hfb_sys/backend/internal/modules/auth"
|
||||
"hfb_sys/backend/internal/modules/dispute"
|
||||
filemodule "hfb_sys/backend/internal/modules/file"
|
||||
"hfb_sys/backend/internal/modules/listing"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
"hfb_sys/backend/internal/modules/order"
|
||||
@@ -108,6 +109,16 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
}
|
||||
systemConfigService := systemconfig.NewService(systemConfigRepo)
|
||||
systemConfigHandler := systemconfig.NewHandler(systemConfigService)
|
||||
var fileStorage *filemodule.Storage
|
||||
if cfg.Storage.Endpoint != "" && cfg.Storage.Bucket != "" {
|
||||
var err error
|
||||
fileStorage, err = filemodule.NewStorage(cfg.Storage)
|
||||
if err != nil {
|
||||
logger.Warn("file storage unavailable; file APIs will return 503", zap.Error(err))
|
||||
}
|
||||
}
|
||||
fileService := filemodule.NewService(fileStorage)
|
||||
fileHandler := filemodule.NewHandler(fileService, fileStorage)
|
||||
requireAuth := middleware.Auth(jwtManager)
|
||||
requireAdmin := middleware.AdminAuth(jwtManager)
|
||||
requireRealname := middleware.RequireRealname(userRepo)
|
||||
@@ -168,6 +179,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
walletRoutes.GET("/ledger", walletHandler.Ledger)
|
||||
}
|
||||
|
||||
fileRoutes := api.Group("/files", requireAuth)
|
||||
{
|
||||
fileRoutes.POST("/upload", fileHandler.Upload)
|
||||
fileRoutes.GET("/object", fileHandler.Object)
|
||||
}
|
||||
|
||||
notificationRoutes := api.Group("/notifications", requireAuth)
|
||||
{
|
||||
notificationRoutes.GET("", notificationHandler.List)
|
||||
@@ -191,6 +208,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.GET("/me", adminAuthHandler.Me)
|
||||
adminRoutes.POST("/auth/logout", adminAuthHandler.Logout)
|
||||
adminRoutes.GET("/dashboard", adminDashboardHandler.Summary)
|
||||
adminRoutes.GET("/files/object", fileHandler.Object)
|
||||
adminRoutes.GET("/users", adminUserHandler.List)
|
||||
adminRoutes.POST("/users/:id/freeze", adminUserHandler.Freeze)
|
||||
adminRoutes.POST("/users/:id/unfreeze", adminUserHandler.Unfreeze)
|
||||
|
||||
Reference in New Issue
Block a user