From 3adba1c529b47eb334ef9ee516ccc22082775e3f Mon Sep 17 00:00:00 2001 From: yml Date: Tue, 2 Jun 2026 17:51:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=8A=E4=BC=A0=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/model/listing.go | 20 + backend/internal/modules/listing/dto.go | 81 +++- backend/internal/modules/listing/handler.go | 51 +++ .../internal/modules/listing/repository.go | 140 +++++++ backend/internal/modules/listing/service.go | 395 ++++++++++++++++++ .../internal/modules/listing/service_test.go | 107 ++++- backend/internal/router/router.go | 6 + backend/migrations/000005_listing_uploads.sql | 23 + docs/api.md | 4 + 9 files changed, 825 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/000005_listing_uploads.sql diff --git a/backend/internal/model/listing.go b/backend/internal/model/listing.go index 6cdac67..40cd26c 100644 --- a/backend/internal/model/listing.go +++ b/backend/internal/model/listing.go @@ -46,3 +46,23 @@ type RentalListing struct { func (RentalListing) TableName() string { 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" +} diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index ad420ff..1853334 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -1,6 +1,10 @@ package listing -import "time" +import ( + "encoding/json" + "strings" + "time" +) type ListingDTO struct { ID uint64 `json:"id"` @@ -116,3 +120,78 @@ type AuditMeta struct { IP 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"` +} diff --git a/backend/internal/modules/listing/handler.go b/backend/internal/modules/listing/handler.go index b1bd219..504eafe 100644 --- a/backend/internal/modules/listing/handler.go +++ b/backend/internal/modules/listing/handler.go @@ -1,7 +1,9 @@ package listing import ( + "encoding/json" "errors" + "io" "net/http" "strconv" "strings" @@ -18,6 +20,8 @@ type Handler struct { storage *filemodule.Storage } +const maxExternalUploadBodyBytes = 256 * 1024 + func NewHandler(service *Service, storage *filemodule.Storage) *Handler { return &Handler{service: service, storage: storage} } @@ -41,6 +45,36 @@ func (h *Handler) Create(c *gin.Context) { 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, `账号资料默认图开放接口上传,待后台补充审核`) +} + func (h *Handler) Update(c *gin.Context) { ownerID, ok := currentUserID(c) if !ok { @@ -512,6 +546,18 @@ func writeListingError(c *gin.Context, err error) { response.BadRequest(c, "哈夫币数量不正确") case errors.Is(err, ErrMissingScreenshot): 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): var levelErr FireLevelTooLowError errors.As(err, &levelErr) @@ -531,3 +577,8 @@ func isFireLevelTooLow(err error) bool { var levelErr FireLevelTooLowError return errors.As(err, &levelErr) } + +func isUploadValidationError(err error) bool { + var validationErr UploadValidationError + return errors.As(err, &validationErr) +} diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 02cf4bf..f625949 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -3,6 +3,7 @@ package listing import ( "encoding/json" "errors" + "fmt" "net/url" "sort" "strconv" @@ -81,6 +82,83 @@ func (r *Repository) Create(ownerID uint64, req CreateRequest, reviewRequired bo 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) { var dto *ListingDTO 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 } +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) { return r.findDTO("l.id = ?", listingID) } diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index a9ece21..0cf8632 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -3,10 +3,12 @@ package listing import ( "encoding/json" "errors" + "fmt" "math" "regexp" "strconv" "strings" + "time" ) var ( @@ -20,6 +22,11 @@ var ( ErrDepositTooLow = errors.New("listing deposit too low") ErrInvalidHafCoin = errors.New("invalid haf coin amount") 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 { @@ -35,6 +42,8 @@ const ( reviewRequiredConfigKey = "listing.review_required" publishOptionsConfigKey = "listing.publish_options" defaultFireLevelMin = 38 + maxExternalUploadItems = 10 + defaultUploadScreenshot = "/api/listings/default-upload-screenshot" ) var priceNumberPattern = regexp.MustCompile(`(\d+(?:\.\d+)?)`) @@ -47,6 +56,22 @@ func (e FireLevelTooLowError) Error() string { 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 { 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) } +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) { if s.repo == nil { return nil, ErrDependencyUnavailable @@ -237,6 +333,305 @@ func validateRequest(req CreateRequest, rules publishRules) error { 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 { if summary == nil { return 0 diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 9c6c2b9..4b0a0ff 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -1,6 +1,9 @@ package listing -import "testing" +import ( + "encoding/json" + "testing" +) func TestConsumableValueOnlyCountsChargedResources(t *testing.T) { value := consumableValue(map[string]any{ @@ -74,3 +77,105 @@ func TestApplySellerListingPriceKeepsFallbackPrice(t *testing.T) { 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 +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b8f3742..9117dac 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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("/public/files/object", fileHandler.PublicObject) + openRoutes := api.Group("/open") + { + openRoutes.POST("/listing-uploads", listingHandler.ImportExternalUpload) + } + authRoutes := api.Group("/auth") { 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.GET("", listingHandler.ListPublic) + listingRoutes.GET("/default-upload-screenshot", listingHandler.DefaultUploadScreenshot) listingRoutes.GET("/:id/cover", listingHandler.Cover) listingRoutes.GET("/:id/screenshots/:index", listingHandler.Screenshot) listingRoutes.GET("/:id", listingHandler.FindPublic) diff --git a/backend/migrations/000005_listing_uploads.sql b/backend/migrations/000005_listing_uploads.sql new file mode 100644 index 0000000..e8d491f --- /dev/null +++ b/backend/migrations/000005_listing_uploads.sql @@ -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; diff --git a/docs/api.md b/docs/api.md index 5c7df87..705d738 100644 --- a/docs/api.md +++ b/docs/api.md @@ -22,7 +22,9 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `GET /api/me` - `POST /api/realname/start` - `GET /api/realname/status` +- `POST /api/open/listing-uploads` - `GET /api/listings` +- `GET /api/listings/default-upload-screenshot` - `GET /api/listings/{id}` - `POST /api/listings` - `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/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=...`。 订单超时扫描任务为后端内部任务,不暴露公开 API;超时阈值通过 `/api/admin/system-configs` 调整。