增加上传接口
This commit is contained in:
@@ -46,3 +46,23 @@ type RentalListing struct {
|
|||||||
func (RentalListing) TableName() string {
|
func (RentalListing) TableName() string {
|
||||||
return "rental_listings"
|
return "rental_listings"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ListingUpload struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
|
UploaderName string `gorm:"size:64;not null;index" json:"uploader_name"`
|
||||||
|
MatchedAdminID *uint64 `gorm:"index" json:"matched_admin_id"`
|
||||||
|
OwnerID *uint64 `gorm:"index" json:"owner_id"`
|
||||||
|
ClientUploadTime *time.Time `json:"client_upload_time"`
|
||||||
|
ClientIP string `gorm:"size:64;not null;default:''" json:"client_ip"`
|
||||||
|
RawPayload datatypes.JSON `json:"raw_payload"`
|
||||||
|
ParsedPayload datatypes.JSON `json:"parsed_payload"`
|
||||||
|
ListingID *uint64 `gorm:"index" json:"listing_id"`
|
||||||
|
Status string `gorm:"size:32;not null;default:'draft_created'" json:"status"`
|
||||||
|
ErrorMessage string `gorm:"size:255;not null;default:''" json:"error_message"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ListingUpload) TableName() string {
|
||||||
|
return "listing_uploads"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package listing
|
package listing
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type ListingDTO struct {
|
type ListingDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
@@ -116,3 +120,78 @@ type AuditMeta struct {
|
|||||||
IP string
|
IP string
|
||||||
UserAgent string
|
UserAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExternalUploadRequest struct {
|
||||||
|
UploadTime int64 `json:"uploadTime"`
|
||||||
|
UploaderName string `json:"uploaderName"`
|
||||||
|
Uploaderame string `json:"uploaderame"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ExternalUploadRequest) normalizedUploaderName() string {
|
||||||
|
if name := strings.TrimSpace(r.UploaderName); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(r.Uploaderame)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalAccountData struct {
|
||||||
|
LoginMethod string `json:"loginMethod"`
|
||||||
|
Rank string `json:"rank"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
SafeSlots int `json:"safeSlots"`
|
||||||
|
SecretKD float64 `json:"secretKD"`
|
||||||
|
DailyLossM float64 `json:"dailyLossM"`
|
||||||
|
Deposit float64 `json:"deposit"`
|
||||||
|
BanRecord string `json:"banRecord"`
|
||||||
|
CommonRegion string `json:"commonRegion"`
|
||||||
|
ContactPhone string `json:"contactPhone"`
|
||||||
|
OwnerOnlineTime string `json:"ownerOnlineTime"`
|
||||||
|
Currency ExternalUploadCurrency `json:"currency"`
|
||||||
|
DailyConsumption ExternalDailyConsumption `json:"dailyConsumption"`
|
||||||
|
Inventory ExternalUploadInventory `json:"inventory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalUploadCurrency struct {
|
||||||
|
HafuCoin float64 `json:"hafuCoin"`
|
||||||
|
RecycleRatio float64 `json:"recycleRatio"`
|
||||||
|
RecycleRent float64 `json:"recycleRent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalDailyConsumption struct {
|
||||||
|
Stamina int `json:"stamina"`
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalUploadInventory struct {
|
||||||
|
AWMBullets int `json:"awmBullets"`
|
||||||
|
Level6Helmets int `json:"level6Helmets"`
|
||||||
|
Level6Armor int `json:"level6Armor"`
|
||||||
|
Skins []string `json:"skins"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalUploadMeta struct {
|
||||||
|
IP string
|
||||||
|
UserAgent string
|
||||||
|
RawPayload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalUploadResult struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
ListingID uint64 `json:"listing_id,omitempty"`
|
||||||
|
AccountID uint64 `json:"account_id,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
ReviewStatus string `json:"review_status,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalUploadResponse struct {
|
||||||
|
ListingID uint64 `json:"listing_id,omitempty"`
|
||||||
|
AccountID uint64 `json:"account_id,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
ReviewStatus string `json:"review_status,omitempty"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Success int `json:"success"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Items []ExternalUploadResult `json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package listing
|
package listing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,6 +20,8 @@ type Handler struct {
|
|||||||
storage *filemodule.Storage
|
storage *filemodule.Storage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxExternalUploadBodyBytes = 256 * 1024
|
||||||
|
|
||||||
func NewHandler(service *Service, storage *filemodule.Storage) *Handler {
|
func NewHandler(service *Service, storage *filemodule.Storage) *Handler {
|
||||||
return &Handler{service: service, storage: storage}
|
return &Handler{service: service, storage: storage}
|
||||||
}
|
}
|
||||||
@@ -41,6 +45,36 @@ func (h *Handler) Create(c *gin.Context) {
|
|||||||
response.Created(c, item)
|
response.Created(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ImportExternalUpload(c *gin.Context) {
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxExternalUploadBodyBytes)
|
||||||
|
raw, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "上传内容过大或读取失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req ExternalUploadRequest
|
||||||
|
if err := json.Unmarshal(raw, &req); err != nil {
|
||||||
|
response.BadRequest(c, "上传 JSON 格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.ImportExternalUpload(req, ExternalUploadMeta{
|
||||||
|
IP: c.ClientIP(),
|
||||||
|
UserAgent: c.GetHeader("User-Agent"),
|
||||||
|
RawPayload: raw,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeListingError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Created(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DefaultUploadScreenshot(c *gin.Context) {
|
||||||
|
c.Header("Content-Type", "image/svg+xml; charset=utf-8")
|
||||||
|
c.Header("Cache-Control", "public, max-age=86400")
|
||||||
|
c.String(http.StatusOK, `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540"><rect width="960" height="540" fill="#f3f4f6"/><rect x="72" y="72" width="816" height="396" rx="24" fill="#ffffff" stroke="#d1d5db" stroke-width="4"/><text x="480" y="248" text-anchor="middle" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#374151">账号资料默认图</text><text x="480" y="310" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" fill="#6b7280">开放接口上传,待后台补充审核</text></svg>`)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) Update(c *gin.Context) {
|
func (h *Handler) Update(c *gin.Context) {
|
||||||
ownerID, ok := currentUserID(c)
|
ownerID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -512,6 +546,18 @@ func writeListingError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "哈夫币数量不正确")
|
response.BadRequest(c, "哈夫币数量不正确")
|
||||||
case errors.Is(err, ErrMissingScreenshot):
|
case errors.Is(err, ErrMissingScreenshot):
|
||||||
response.BadRequest(c, "请至少上传一张账号截图")
|
response.BadRequest(c, "请至少上传一张账号截图")
|
||||||
|
case errors.Is(err, ErrMissingUploaderName):
|
||||||
|
response.BadRequest(c, "上传人名称不能为空")
|
||||||
|
case errors.Is(err, ErrMissingUploadData):
|
||||||
|
response.BadRequest(c, "上传账号数据不能为空")
|
||||||
|
case errors.Is(err, ErrUploaderNotFound):
|
||||||
|
response.BadRequest(c, "未匹配到可用后台用户")
|
||||||
|
case errors.Is(err, ErrUploaderAmbiguous):
|
||||||
|
response.BadRequest(c, "上传人名称匹配到多个后台用户,请使用唯一用户名")
|
||||||
|
case errors.Is(err, ErrTooManyUploadItems):
|
||||||
|
response.BadRequest(c, "单次上传账号数量过多")
|
||||||
|
case isUploadValidationError(err):
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
case isFireLevelTooLow(err):
|
case isFireLevelTooLow(err):
|
||||||
var levelErr FireLevelTooLowError
|
var levelErr FireLevelTooLowError
|
||||||
errors.As(err, &levelErr)
|
errors.As(err, &levelErr)
|
||||||
@@ -531,3 +577,8 @@ func isFireLevelTooLow(err error) bool {
|
|||||||
var levelErr FireLevelTooLowError
|
var levelErr FireLevelTooLowError
|
||||||
return errors.As(err, &levelErr)
|
return errors.As(err, &levelErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isUploadValidationError(err error) bool {
|
||||||
|
var validationErr UploadValidationError
|
||||||
|
return errors.As(err, &validationErr)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package listing
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -81,6 +82,83 @@ func (r *Repository) Create(ownerID uint64, req CreateRequest, reviewRequired bo
|
|||||||
return dto, err
|
return dto, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type externalUploadCreate struct {
|
||||||
|
UploaderName string
|
||||||
|
ClientUploadTime *time.Time
|
||||||
|
ClientIP string
|
||||||
|
RawPayload []byte
|
||||||
|
ParsedPayload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) CreateFromExternalUpload(upload externalUploadCreate, req CreateRequest) (*ListingDTO, error) {
|
||||||
|
var dto *ListingDTO
|
||||||
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
admin, err := r.findActiveUploadAdmin(tx, upload.UploaderName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
owner, err := r.ensureUploadOwnerUser(tx, admin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
screenshots, err := marshalScreenshots(req.ScreenshotURLS)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
assetSummary, err := marshalAssetSummary(req.AssetSummary)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
account := model.GameAccount{
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
GameName: "delta_force",
|
||||||
|
ServerRegion: req.ServerRegion,
|
||||||
|
LoginPlatform: req.LoginPlatform,
|
||||||
|
Title: req.Title,
|
||||||
|
Description: req.Description,
|
||||||
|
RankLevel: req.RankLevel,
|
||||||
|
HafCoinAmount: req.HafCoinAmount,
|
||||||
|
AssetSummary: assetSummary,
|
||||||
|
ScreenshotURLS: screenshots,
|
||||||
|
Status: "draft",
|
||||||
|
}
|
||||||
|
if err := tx.Create(&account).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
listing := model.RentalListing{
|
||||||
|
AccountID: account.ID,
|
||||||
|
OwnerID: owner.ID,
|
||||||
|
Price: normalizedListingPrice(req),
|
||||||
|
DepositAmount: roundMoney(req.DepositAmount),
|
||||||
|
Status: "draft",
|
||||||
|
ReviewStatus: "pending",
|
||||||
|
}
|
||||||
|
if err := tx.Create(&listing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
matchedAdminID := admin.ID
|
||||||
|
ownerID := owner.ID
|
||||||
|
listingID := listing.ID
|
||||||
|
uploadRow := model.ListingUpload{
|
||||||
|
UploaderName: upload.UploaderName,
|
||||||
|
MatchedAdminID: &matchedAdminID,
|
||||||
|
OwnerID: &ownerID,
|
||||||
|
ClientUploadTime: upload.ClientUploadTime,
|
||||||
|
ClientIP: upload.ClientIP,
|
||||||
|
RawPayload: datatypes.JSON(upload.RawPayload),
|
||||||
|
ParsedPayload: datatypes.JSON(upload.ParsedPayload),
|
||||||
|
ListingID: &listingID,
|
||||||
|
Status: "draft_created",
|
||||||
|
}
|
||||||
|
if err := tx.Create(&uploadRow).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dto = toDTO(account, listing)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return dto, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) Update(ownerID uint64, listingID uint64, req UpdateRequest, reviewRequired bool) (*ListingDTO, error) {
|
func (r *Repository) Update(ownerID uint64, listingID uint64, req UpdateRequest, reviewRequired bool) (*ListingDTO, error) {
|
||||||
var dto *ListingDTO
|
var dto *ListingDTO
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -224,6 +302,68 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) findActiveUploadAdmin(tx *gorm.DB, uploaderName string) (*model.AdminUser, error) {
|
||||||
|
uploaderName = strings.TrimSpace(uploaderName)
|
||||||
|
var admin model.AdminUser
|
||||||
|
if err := tx.Where("username = ? AND status = ?", uploaderName, "active").First(&admin).Error; err == nil {
|
||||||
|
return &admin, nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var admins []model.AdminUser
|
||||||
|
if err := tx.Where("nickname = ? AND status = ?", uploaderName, "active").Limit(2).Find(&admins).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch len(admins) {
|
||||||
|
case 0:
|
||||||
|
return nil, ErrUploaderNotFound
|
||||||
|
case 1:
|
||||||
|
return &admins[0], nil
|
||||||
|
default:
|
||||||
|
return nil, ErrUploaderAmbiguous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ensureUploadOwnerUser(tx *gorm.DB, admin *model.AdminUser) (*model.User, error) {
|
||||||
|
phone := fmt.Sprintf("admin:%d", admin.ID)
|
||||||
|
nickname := strings.TrimSpace(admin.Nickname)
|
||||||
|
if nickname == "" {
|
||||||
|
nickname = admin.Username
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
err := tx.Where("phone = ?", phone).First(&user).Error
|
||||||
|
if err == nil {
|
||||||
|
updates := map[string]any{
|
||||||
|
"nickname": nickname,
|
||||||
|
"status": "active",
|
||||||
|
"realname_status": "verified",
|
||||||
|
}
|
||||||
|
if err := tx.Model(&user).Updates(updates).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user.Nickname = nickname
|
||||||
|
user.Status = "active"
|
||||||
|
user.RealnameStatus = "verified"
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user = model.User{
|
||||||
|
Phone: phone,
|
||||||
|
Nickname: nickname,
|
||||||
|
RealnameStatus: "verified",
|
||||||
|
RiskStatus: "normal",
|
||||||
|
CreditScore: 100,
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
if err := tx.Create(&user).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) FindAdmin(listingID uint64) (*ListingDTO, error) {
|
func (r *Repository) FindAdmin(listingID uint64) (*ListingDTO, error) {
|
||||||
return r.findDTO("l.id = ?", listingID)
|
return r.findDTO("l.id = ?", listingID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package listing
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -20,6 +22,11 @@ var (
|
|||||||
ErrDepositTooLow = errors.New("listing deposit too low")
|
ErrDepositTooLow = errors.New("listing deposit too low")
|
||||||
ErrInvalidHafCoin = errors.New("invalid haf coin amount")
|
ErrInvalidHafCoin = errors.New("invalid haf coin amount")
|
||||||
ErrMissingScreenshot = errors.New("missing screenshot")
|
ErrMissingScreenshot = errors.New("missing screenshot")
|
||||||
|
ErrMissingUploaderName = errors.New("missing uploader name")
|
||||||
|
ErrMissingUploadData = errors.New("missing upload data")
|
||||||
|
ErrUploaderNotFound = errors.New("uploader not found")
|
||||||
|
ErrUploaderAmbiguous = errors.New("uploader ambiguous")
|
||||||
|
ErrTooManyUploadItems = errors.New("too many upload items")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -35,6 +42,8 @@ const (
|
|||||||
reviewRequiredConfigKey = "listing.review_required"
|
reviewRequiredConfigKey = "listing.review_required"
|
||||||
publishOptionsConfigKey = "listing.publish_options"
|
publishOptionsConfigKey = "listing.publish_options"
|
||||||
defaultFireLevelMin = 38
|
defaultFireLevelMin = 38
|
||||||
|
maxExternalUploadItems = 10
|
||||||
|
defaultUploadScreenshot = "/api/listings/default-upload-screenshot"
|
||||||
)
|
)
|
||||||
|
|
||||||
var priceNumberPattern = regexp.MustCompile(`(\d+(?:\.\d+)?)`)
|
var priceNumberPattern = regexp.MustCompile(`(\d+(?:\.\d+)?)`)
|
||||||
@@ -47,6 +56,22 @@ func (e FireLevelTooLowError) Error() string {
|
|||||||
return "fire level too low"
|
return "fire level too low"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UploadValidationError struct {
|
||||||
|
Missing []string
|
||||||
|
Invalid []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e UploadValidationError) Error() string {
|
||||||
|
parts := make([]string, 0, 2)
|
||||||
|
if len(e.Missing) > 0 {
|
||||||
|
parts = append(parts, "缺少必填字段:"+strings.Join(e.Missing, "、"))
|
||||||
|
}
|
||||||
|
if len(e.Invalid) > 0 {
|
||||||
|
parts = append(parts, "字段格式不正确:"+strings.Join(e.Invalid, "、"))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ";")
|
||||||
|
}
|
||||||
|
|
||||||
func NewService(repo *Repository, config ConfigReader) *Service {
|
func NewService(repo *Repository, config ConfigReader) *Service {
|
||||||
return &Service{repo: repo, config: config}
|
return &Service{repo: repo, config: config}
|
||||||
}
|
}
|
||||||
@@ -69,6 +94,77 @@ func (s *Service) Create(ownerID uint64, req CreateRequest) (*ListingDTO, error)
|
|||||||
return s.repo.Create(ownerID, req, reviewRequired)
|
return s.repo.Create(ownerID, req, reviewRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ImportExternalUpload(req ExternalUploadRequest, meta ExternalUploadMeta) (*ExternalUploadResponse, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
uploaderName := req.normalizedUploaderName()
|
||||||
|
if uploaderName == "" {
|
||||||
|
return nil, ErrMissingUploaderName
|
||||||
|
}
|
||||||
|
items, err := parseExternalUploadItems(req.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(items) > maxExternalUploadItems {
|
||||||
|
return nil, ErrTooManyUploadItems
|
||||||
|
}
|
||||||
|
rules, err := s.publishRules()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clientUploadTime := parseClientUploadTime(req.UploadTime)
|
||||||
|
results := make([]ExternalUploadResult, 0, len(items))
|
||||||
|
resp := &ExternalUploadResponse{Total: len(items)}
|
||||||
|
for index, item := range items {
|
||||||
|
createReq := externalAccountToCreateRequest(uploaderName, req.UploadTime, item)
|
||||||
|
if err := validateRequest(createReq, rules); err != nil {
|
||||||
|
if len(items) == 1 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp.Failed++
|
||||||
|
results = append(results, ExternalUploadResult{Index: index, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsedPayload, _ := json.Marshal(item)
|
||||||
|
dto, err := s.repo.CreateFromExternalUpload(externalUploadCreate{
|
||||||
|
UploaderName: uploaderName,
|
||||||
|
ClientUploadTime: clientUploadTime,
|
||||||
|
ClientIP: meta.IP,
|
||||||
|
RawPayload: meta.RawPayload,
|
||||||
|
ParsedPayload: parsedPayload,
|
||||||
|
}, createReq)
|
||||||
|
if err != nil {
|
||||||
|
if len(items) == 1 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp.Failed++
|
||||||
|
results = append(results, ExternalUploadResult{Index: index, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Success++
|
||||||
|
result := ExternalUploadResult{
|
||||||
|
Index: index,
|
||||||
|
ListingID: dto.ID,
|
||||||
|
AccountID: dto.AccountID,
|
||||||
|
Status: dto.Status,
|
||||||
|
ReviewStatus: dto.ReviewStatus,
|
||||||
|
}
|
||||||
|
results = append(results, result)
|
||||||
|
if len(items) == 1 {
|
||||||
|
resp.ListingID = dto.ID
|
||||||
|
resp.AccountID = dto.AccountID
|
||||||
|
resp.Status = dto.Status
|
||||||
|
resp.ReviewStatus = dto.ReviewStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.Items = results
|
||||||
|
if resp.Success == 0 && resp.Failed > 0 {
|
||||||
|
return resp, ErrInvalidInput
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Update(ownerID uint64, id uint64, req UpdateRequest) (*ListingDTO, error) {
|
func (s *Service) Update(ownerID uint64, id uint64, req UpdateRequest) (*ListingDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
@@ -237,6 +333,305 @@ func validateRequest(req CreateRequest, rules publishRules) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseExternalUploadItems(raw json.RawMessage) ([]ExternalAccountData, error) {
|
||||||
|
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" || string(raw) == "null" {
|
||||||
|
return nil, ErrMissingUploadData
|
||||||
|
}
|
||||||
|
var singleRaw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &singleRaw); err == nil {
|
||||||
|
if err := validateExternalUploadRaw(singleRaw, "data"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var single ExternalAccountData
|
||||||
|
if err := json.Unmarshal(raw, &single); err != nil {
|
||||||
|
return nil, ErrMissingUploadData
|
||||||
|
}
|
||||||
|
return []ExternalAccountData{single}, nil
|
||||||
|
}
|
||||||
|
var rawItems []json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &rawItems); err != nil {
|
||||||
|
return nil, ErrMissingUploadData
|
||||||
|
}
|
||||||
|
if len(rawItems) == 0 {
|
||||||
|
return nil, ErrMissingUploadData
|
||||||
|
}
|
||||||
|
items := make([]ExternalAccountData, 0, len(rawItems))
|
||||||
|
for index, rawItem := range rawItems {
|
||||||
|
var rawMap map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(rawItem, &rawMap); err != nil {
|
||||||
|
return nil, UploadValidationError{Invalid: []string{fmt.Sprintf("data[%d]", index)}}
|
||||||
|
}
|
||||||
|
prefix := fmt.Sprintf("data[%d]", index)
|
||||||
|
if err := validateExternalUploadRaw(rawMap, prefix); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var item ExternalAccountData
|
||||||
|
if err := json.Unmarshal(rawItem, &item); err != nil {
|
||||||
|
return nil, UploadValidationError{Invalid: []string{prefix}}
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateExternalUploadRaw(raw map[string]json.RawMessage, prefix string) error {
|
||||||
|
var validation UploadValidationError
|
||||||
|
requireStringField(raw, prefix, "loginMethod", &validation)
|
||||||
|
requireStringField(raw, prefix, "rank", &validation)
|
||||||
|
requireNumberField(raw, prefix, "level", &validation)
|
||||||
|
requireNumberField(raw, prefix, "safeSlots", &validation)
|
||||||
|
requireNumberField(raw, prefix, "secretKD", &validation)
|
||||||
|
requireNumberField(raw, prefix, "deposit", &validation)
|
||||||
|
requireNumberField(raw, prefix, "dailyLossM", &validation)
|
||||||
|
|
||||||
|
currency := requireObjectField(raw, prefix, "currency", &validation)
|
||||||
|
requireNumberField(currency, prefix+".currency", "hafuCoin", &validation)
|
||||||
|
requireNumberField(currency, prefix+".currency", "recycleRatio", &validation)
|
||||||
|
requireNumberField(currency, prefix+".currency", "recycleRent", &validation)
|
||||||
|
|
||||||
|
dailyConsumption := requireObjectField(raw, prefix, "dailyConsumption", &validation)
|
||||||
|
requireNumberField(dailyConsumption, prefix+".dailyConsumption", "stamina", &validation)
|
||||||
|
requireNumberField(dailyConsumption, prefix+".dailyConsumption", "weight", &validation)
|
||||||
|
|
||||||
|
if len(validation.Missing) > 0 || len(validation.Invalid) > 0 {
|
||||||
|
return validation
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireObjectField(raw map[string]json.RawMessage, prefix string, field string, validation *UploadValidationError) map[string]json.RawMessage {
|
||||||
|
if raw == nil {
|
||||||
|
validation.Missing = append(validation.Missing, prefix+"."+field)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value, ok := raw[field]
|
||||||
|
if !ok || isJSONNull(value) {
|
||||||
|
validation.Missing = append(validation.Missing, prefix+"."+field)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var object map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(value, &object); err != nil {
|
||||||
|
validation.Invalid = append(validation.Invalid, prefix+"."+field)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireStringField(raw map[string]json.RawMessage, prefix string, field string, validation *UploadValidationError) {
|
||||||
|
value, ok := raw[field]
|
||||||
|
if !ok || isJSONNull(value) {
|
||||||
|
validation.Missing = append(validation.Missing, prefix+"."+field)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(value, &text); err != nil {
|
||||||
|
validation.Invalid = append(validation.Invalid, prefix+"."+field)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
validation.Missing = append(validation.Missing, prefix+"."+field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireNumberField(raw map[string]json.RawMessage, prefix string, field string, validation *UploadValidationError) {
|
||||||
|
if raw == nil {
|
||||||
|
validation.Missing = append(validation.Missing, prefix+"."+field)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value, ok := raw[field]
|
||||||
|
if !ok || isJSONNull(value) {
|
||||||
|
validation.Missing = append(validation.Missing, prefix+"."+field)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var number json.Number
|
||||||
|
decoder := json.NewDecoder(strings.NewReader(string(value)))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(&number); err != nil {
|
||||||
|
validation.Invalid = append(validation.Invalid, prefix+"."+field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isJSONNull(value json.RawMessage) bool {
|
||||||
|
return strings.TrimSpace(string(value)) == "null"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseClientUploadTime(value int64) *time.Time {
|
||||||
|
if value <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if value > 1_000_000_000_000 {
|
||||||
|
parsed := time.UnixMilli(value)
|
||||||
|
return &parsed
|
||||||
|
}
|
||||||
|
parsed := time.Unix(value, 0)
|
||||||
|
return &parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalAccountToCreateRequest(uploaderName string, uploadTime int64, item ExternalAccountData) CreateRequest {
|
||||||
|
hafCoinM := item.Currency.HafuCoin
|
||||||
|
price := item.Currency.RecycleRent
|
||||||
|
ratio := item.Currency.RecycleRatio
|
||||||
|
insurance := insuranceFromSafeSlots(item.SafeSlots)
|
||||||
|
staminaLevel := levelText(item.DailyConsumption.Stamina)
|
||||||
|
loadLevel := levelText(item.DailyConsumption.Weight)
|
||||||
|
skins := cleanStrings(item.Inventory.Skins)
|
||||||
|
assetSummary := map[string]any{
|
||||||
|
"face_owner": "",
|
||||||
|
"secret_kd": item.SecretKD,
|
||||||
|
"fire_level": item.Level,
|
||||||
|
"daily_loss_m": item.DailyLossM,
|
||||||
|
"publish_ratio": ratio,
|
||||||
|
"season_insurance": insurance,
|
||||||
|
"stamina_level": staminaLevel,
|
||||||
|
"load_level": loadLevel,
|
||||||
|
"resources": externalResources(item.Inventory),
|
||||||
|
"skin_groups": externalSkinGroups(skins),
|
||||||
|
"online_time_text": strings.TrimSpace(item.OwnerOnlineTime),
|
||||||
|
"ban_record": normalizeBanRecord(item.BanRecord),
|
||||||
|
"common_regions": commonRegions(item.CommonRegion),
|
||||||
|
"remark": "开放接口自动上传,等待后台审核。",
|
||||||
|
"import_meta": map[string]any{
|
||||||
|
"uploader_name": uploaderName,
|
||||||
|
"client_upload_time": uploadTime,
|
||||||
|
"contact_phone": strings.TrimSpace(item.ContactPhone),
|
||||||
|
},
|
||||||
|
"price_breakdown": map[string]any{
|
||||||
|
"seller_reference_ratio": ratio,
|
||||||
|
"seller_ratio": ratio,
|
||||||
|
"seller_coin_base_price": price,
|
||||||
|
"seller_total_price": price,
|
||||||
|
"consumable_price": consumableValue(map[string]any{"resources": externalResources(item.Inventory)}),
|
||||||
|
"buyer_coin_base_price": price,
|
||||||
|
"buyer_total_price": price,
|
||||||
|
"buyer_ratio": ratio,
|
||||||
|
"platform_markup_amount": 0,
|
||||||
|
"platform_rule_type": "external_upload",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return CreateRequest{
|
||||||
|
Title: externalUploadTitle(item, insurance, hafCoinM),
|
||||||
|
Description: "开放接口自动上传,等待后台审核。",
|
||||||
|
ServerRegion: serverRegionFromLoginMethod(item.LoginMethod),
|
||||||
|
LoginPlatform: strings.TrimSpace(item.LoginMethod),
|
||||||
|
RankLevel: strings.TrimSpace(item.Rank),
|
||||||
|
HafCoinAmount: int64(math.Round(hafCoinM * 1000000)),
|
||||||
|
AssetSummary: assetSummary,
|
||||||
|
ScreenshotURLS: []string{defaultUploadScreenshot},
|
||||||
|
Price: price,
|
||||||
|
DepositAmount: item.Deposit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalUploadTitle(item ExternalAccountData, insurance string, hafCoinM float64) string {
|
||||||
|
parts := []string{
|
||||||
|
strings.TrimSpace(item.Rank),
|
||||||
|
insurance,
|
||||||
|
fmt.Sprintf("%.1fM", hafCoinM),
|
||||||
|
strings.TrimSpace(item.LoginMethod),
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(strings.Join(cleanStrings(parts), " "))
|
||||||
|
if title == "" {
|
||||||
|
return "开放接口上传账号"
|
||||||
|
}
|
||||||
|
if len([]rune(title)) > 128 {
|
||||||
|
return string([]rune(title)[:128])
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
func serverRegionFromLoginMethod(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(value, "微信"):
|
||||||
|
return "微信"
|
||||||
|
case strings.Contains(strings.ToLower(value), "steam"):
|
||||||
|
return "Steam"
|
||||||
|
default:
|
||||||
|
return "QQ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insuranceFromSafeSlots(value int) string {
|
||||||
|
switch value {
|
||||||
|
case 9:
|
||||||
|
return "3*3"
|
||||||
|
case 6:
|
||||||
|
return "2*3"
|
||||||
|
case 4:
|
||||||
|
return "2*2"
|
||||||
|
case 2:
|
||||||
|
return "2*1"
|
||||||
|
default:
|
||||||
|
if value > 0 {
|
||||||
|
return strconv.Itoa(value) + "格"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func levelText(value int) string {
|
||||||
|
if value <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(value) + "级"
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalResources(inventory ExternalUploadInventory) []any {
|
||||||
|
resources := []any{
|
||||||
|
map[string]any{"key": "awmAmmo", "label": "AWM子弹", "price": "0.6元/发", "quantity": inventory.AWMBullets, "mode": "收费"},
|
||||||
|
map[string]any{"key": "helmet6", "label": "6头", "price": "1.5元/个", "quantity": inventory.Level6Helmets, "mode": "收费"},
|
||||||
|
map[string]any{"key": "armor6", "label": "6甲", "price": "2.5元/个", "quantity": inventory.Level6Armor, "mode": "收费"},
|
||||||
|
}
|
||||||
|
return resources
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalSkinGroups(skins []string) map[string][]string {
|
||||||
|
groups := map[string][]string{
|
||||||
|
"melee": {},
|
||||||
|
"imported": {},
|
||||||
|
}
|
||||||
|
for _, skin := range skins {
|
||||||
|
switch skin {
|
||||||
|
case "坠星者", "暗星", "龙牙", "信条", "怜悯", "赤枭", "影锋", "黑海", "北极星", "电锯惊魂", "处刑者":
|
||||||
|
groups["melee"] = append(groups["melee"], skin)
|
||||||
|
default:
|
||||||
|
groups["imported"] = append(groups["imported"], skin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBanRecord(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
switch value {
|
||||||
|
case "", "无", "无封禁", "无封禁记录":
|
||||||
|
return "无封禁记录"
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commonRegions(value string) []string {
|
||||||
|
return cleanStrings(strings.Split(value, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanStrings(values []string) []string {
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func consumableValue(summary map[string]any) float64 {
|
func consumableValue(summary map[string]any) float64 {
|
||||||
if summary == nil {
|
if summary == nil {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package listing
|
package listing
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestConsumableValueOnlyCountsChargedResources(t *testing.T) {
|
func TestConsumableValueOnlyCountsChargedResources(t *testing.T) {
|
||||||
value := consumableValue(map[string]any{
|
value := consumableValue(map[string]any{
|
||||||
@@ -74,3 +77,105 @@ func TestApplySellerListingPriceKeepsFallbackPrice(t *testing.T) {
|
|||||||
t.Fatalf("expected fallback price 238, got %.2f", item.Price)
|
t.Fatalf("expected fallback price 238, got %.2f", item.Price)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
|
||||||
|
items, err := parseExternalUploadItems(json.RawMessage(validExternalUploadDataJSON()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected single upload parsed, got %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].LoginMethod != "QQ账号密码" {
|
||||||
|
t.Fatalf("unexpected items: %#v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseExternalUploadItemsReportsMissingFields(t *testing.T) {
|
||||||
|
_, err := parseExternalUploadItems(json.RawMessage(`{"loginMethod":"QQ账号密码","currency":{"hafuCoin":197.1}}`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error")
|
||||||
|
}
|
||||||
|
validationErr, ok := err.(UploadValidationError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected UploadValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
expected := []string{"data.rank", "data.level", "data.currency.recycleRatio", "data.dailyConsumption"}
|
||||||
|
for _, field := range expected {
|
||||||
|
if !containsString(validationErr.Missing, field) {
|
||||||
|
t.Fatalf("expected missing %s in %#v", field, validationErr.Missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
|
||||||
|
req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{
|
||||||
|
LoginMethod: "QQ账号密码",
|
||||||
|
Rank: "黑鹰",
|
||||||
|
Level: 60,
|
||||||
|
SafeSlots: 9,
|
||||||
|
SecretKD: 1.6,
|
||||||
|
DailyLossM: 7,
|
||||||
|
Deposit: 400,
|
||||||
|
BanRecord: "无",
|
||||||
|
CommonRegion: "郑州",
|
||||||
|
Currency: ExternalUploadCurrency{
|
||||||
|
HafuCoin: 197.1,
|
||||||
|
RecycleRatio: 43,
|
||||||
|
RecycleRent: 458,
|
||||||
|
},
|
||||||
|
DailyConsumption: ExternalDailyConsumption{Stamina: 7, Weight: 7},
|
||||||
|
Inventory: ExternalUploadInventory{
|
||||||
|
AWMBullets: 45,
|
||||||
|
Level6Helmets: 14,
|
||||||
|
Level6Armor: 17,
|
||||||
|
Skins: []string{"信条", "电锯惊魂"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if req.ServerRegion != "QQ" {
|
||||||
|
t.Fatalf("expected QQ server region, got %q", req.ServerRegion)
|
||||||
|
}
|
||||||
|
if req.HafCoinAmount != 197100000 {
|
||||||
|
t.Fatalf("expected haf coin amount 197100000, got %d", req.HafCoinAmount)
|
||||||
|
}
|
||||||
|
if req.Price != 458 || req.DepositAmount != 400 {
|
||||||
|
t.Fatalf("unexpected price/deposit %.2f/%.2f", req.Price, req.DepositAmount)
|
||||||
|
}
|
||||||
|
if req.AssetSummary["season_insurance"] != "3*3" {
|
||||||
|
t.Fatalf("expected 3*3 insurance, got %#v", req.AssetSummary["season_insurance"])
|
||||||
|
}
|
||||||
|
if req.AssetSummary["daily_loss_m"] != float64(7) {
|
||||||
|
t.Fatalf("expected daily loss 7, got %#v", req.AssetSummary["daily_loss_m"])
|
||||||
|
}
|
||||||
|
if req.AssetSummary["stamina_level"] != "7级" || req.AssetSummary["load_level"] != "7级" {
|
||||||
|
t.Fatalf("unexpected stamina/load: %#v", req.AssetSummary)
|
||||||
|
}
|
||||||
|
groups := req.AssetSummary["skin_groups"].(map[string][]string)
|
||||||
|
if len(groups["melee"]) != 2 {
|
||||||
|
t.Fatalf("expected melee skins mapped, got %#v", groups)
|
||||||
|
}
|
||||||
|
if len(req.ScreenshotURLS) != 1 || req.ScreenshotURLS[0] != defaultUploadScreenshot {
|
||||||
|
t.Fatalf("expected default screenshot, got %#v", req.ScreenshotURLS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validExternalUploadDataJSON() string {
|
||||||
|
return `{
|
||||||
|
"loginMethod":"QQ账号密码",
|
||||||
|
"rank":"黑鹰",
|
||||||
|
"level":60,
|
||||||
|
"safeSlots":9,
|
||||||
|
"secretKD":1.6,
|
||||||
|
"dailyLossM":7,
|
||||||
|
"deposit":400,
|
||||||
|
"currency":{"hafuCoin":197.1,"recycleRatio":43,"recycleRent":458},
|
||||||
|
"dailyConsumption":{"stamina":7,"weight":7}
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, target string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,6 +172,11 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
api.GET("/mobile-home-config", systemConfigHandler.HomeConfig)
|
api.GET("/mobile-home-config", systemConfigHandler.HomeConfig)
|
||||||
api.GET("/public/files/object", fileHandler.PublicObject)
|
api.GET("/public/files/object", fileHandler.PublicObject)
|
||||||
|
|
||||||
|
openRoutes := api.Group("/open")
|
||||||
|
{
|
||||||
|
openRoutes.POST("/listing-uploads", listingHandler.ImportExternalUpload)
|
||||||
|
}
|
||||||
|
|
||||||
authRoutes := api.Group("/auth")
|
authRoutes := api.Group("/auth")
|
||||||
{
|
{
|
||||||
authRoutes.POST("/sms/send", authHandler.SendSMS)
|
authRoutes.POST("/sms/send", authHandler.SendSMS)
|
||||||
@@ -186,6 +191,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
listingRoutes := api.Group("/listings")
|
listingRoutes := api.Group("/listings")
|
||||||
{
|
{
|
||||||
listingRoutes.GET("", listingHandler.ListPublic)
|
listingRoutes.GET("", listingHandler.ListPublic)
|
||||||
|
listingRoutes.GET("/default-upload-screenshot", listingHandler.DefaultUploadScreenshot)
|
||||||
listingRoutes.GET("/:id/cover", listingHandler.Cover)
|
listingRoutes.GET("/:id/cover", listingHandler.Cover)
|
||||||
listingRoutes.GET("/:id/screenshots/:index", listingHandler.Screenshot)
|
listingRoutes.GET("/:id/screenshots/:index", listingHandler.Screenshot)
|
||||||
listingRoutes.GET("/:id", listingHandler.FindPublic)
|
listingRoutes.GET("/:id", listingHandler.FindPublic)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- -------------------------------------------
|
||||||
|
-- 开放上传记录
|
||||||
|
-- -------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE listing_uploads (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
uploader_name VARCHAR(64) NOT NULL,
|
||||||
|
matched_admin_id BIGINT UNSIGNED NULL,
|
||||||
|
owner_id BIGINT UNSIGNED NULL,
|
||||||
|
client_upload_time DATETIME NULL,
|
||||||
|
client_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
raw_payload JSON NULL,
|
||||||
|
parsed_payload JSON NULL,
|
||||||
|
listing_id BIGINT UNSIGNED NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'draft_created',
|
||||||
|
error_message VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_listing_uploads_uploader_name (uploader_name),
|
||||||
|
KEY idx_listing_uploads_matched_admin_id (matched_admin_id),
|
||||||
|
KEY idx_listing_uploads_owner_id (owner_id),
|
||||||
|
KEY idx_listing_uploads_listing_id (listing_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -22,7 +22,9 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
|||||||
- `GET /api/me`
|
- `GET /api/me`
|
||||||
- `POST /api/realname/start`
|
- `POST /api/realname/start`
|
||||||
- `GET /api/realname/status`
|
- `GET /api/realname/status`
|
||||||
|
- `POST /api/open/listing-uploads`
|
||||||
- `GET /api/listings`
|
- `GET /api/listings`
|
||||||
|
- `GET /api/listings/default-upload-screenshot`
|
||||||
- `GET /api/listings/{id}`
|
- `GET /api/listings/{id}`
|
||||||
- `POST /api/listings`
|
- `POST /api/listings`
|
||||||
- `PUT /api/listings/{id}`
|
- `PUT /api/listings/{id}`
|
||||||
@@ -80,6 +82,8 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。
|
|||||||
|
|
||||||
说明:`POST /api/listings` 和 `PUT /api/listings/{id}` 支持 `screenshot_urls` 数组,用于保存号主上传的账号资产截图地址。`GET /api/admin/listings` 支持按 `owner_id`、`status`、`review_status` 和 `limit` 查询商品。`GET /api/admin/wallet/ledger` 支持按 `user_id`、`order_id`、`biz_type` 和 `limit` 查询最近资金流水。`GET /api/admin/audit-logs` 支持按 `actor_id`、`action`、`biz_type` 和 `limit` 查询最近审计日志。`/api/admin/*` 当前已使用独立后台登录,后续接入 RBAC 和 Casbin 权限后再按角色收紧访问控制。
|
说明:`POST /api/listings` 和 `PUT /api/listings/{id}` 支持 `screenshot_urls` 数组,用于保存号主上传的账号资产截图地址。`GET /api/admin/listings` 支持按 `owner_id`、`status`、`review_status` 和 `limit` 查询商品。`GET /api/admin/wallet/ledger` 支持按 `user_id`、`order_id`、`biz_type` 和 `limit` 查询最近资金流水。`GET /api/admin/audit-logs` 支持按 `actor_id`、`action`、`biz_type` 和 `limit` 查询最近审计日志。`/api/admin/*` 当前已使用独立后台登录,后续接入 RBAC 和 Casbin 权限后再按角色收紧访问控制。
|
||||||
|
|
||||||
|
开放上传说明:`POST /api/open/listing-uploads` 不需要登录鉴权,用于接收外部软件上传的账号资料。请求体包含 `uploadTime`、`uploaderName` 和 `data`,默认按单条账号对象处理,也兼容少量数组。后端会按 `uploaderName` 优先匹配 `admin_users.username`,未匹配时再匹配唯一 `nickname`,只使用 `status = active` 的后台用户;匹配成功后自动创建/复用对应的客服代发布普通用户,生成 `draft` + `pending` 的待审核商品,并使用 `/api/listings/default-upload-screenshot` 作为默认账号截图。上传原始内容和转换结果记录在 `listing_uploads` 表中。
|
||||||
|
|
||||||
文件上传说明:`POST /api/files/upload` 使用 `multipart/form-data`,文件字段名为 `file`,可选 `scene` 为 `listing`、`handoff`、`dispute`、`realname`、`avatar`;当前允许 10MB 内的 JPG、PNG、WebP 和 PDF。返回的 `url` 为后端代理访问地址。后台查看私有文件使用 `GET /api/admin/files/object?key=...`。
|
文件上传说明:`POST /api/files/upload` 使用 `multipart/form-data`,文件字段名为 `file`,可选 `scene` 为 `listing`、`handoff`、`dispute`、`realname`、`avatar`;当前允许 10MB 内的 JPG、PNG、WebP 和 PDF。返回的 `url` 为后端代理访问地址。后台查看私有文件使用 `GET /api/admin/files/object?key=...`。
|
||||||
|
|
||||||
订单超时扫描任务为后端内部任务,不暴露公开 API;超时阈值通过 `/api/admin/system-configs` 调整。
|
订单超时扫描任务为后端内部任务,不暴露公开 API;超时阈值通过 `/api/admin/system-configs` 调整。
|
||||||
|
|||||||
Reference in New Issue
Block a user