792 lines
22 KiB
Go
792 lines
22 KiB
Go
package listing
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
var (
|
||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||
ErrInvalidInput = errors.New("invalid listing input")
|
||
ErrListingLocked = errors.New("listing locked")
|
||
ErrMissingTitle = errors.New("missing listing title")
|
||
ErrMissingServerRegion = errors.New("missing server region")
|
||
ErrInvalidPrice = errors.New("invalid listing price")
|
||
ErrInvalidDeposit = errors.New("invalid listing deposit")
|
||
ErrDepositTooLow = errors.New("listing deposit too low")
|
||
ErrInvalidHafCoin = errors.New("invalid haf coin amount")
|
||
ErrMissingScreenshot = errors.New("missing screenshot")
|
||
ErrAgreementRequired = errors.New("listing publish agreement required")
|
||
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 {
|
||
repo *Repository
|
||
config ConfigReader
|
||
}
|
||
|
||
type ConfigReader interface {
|
||
FindValue(ctx context.Context, key string) (string, error)
|
||
}
|
||
|
||
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+)?)`)
|
||
|
||
type FireLevelTooLowError struct {
|
||
Min int
|
||
}
|
||
|
||
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}
|
||
}
|
||
|
||
func (s *Service) Create(ctx context.Context, ownerID uint64, req CreateRequest) (*ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
if !req.AgreedVirtualAssetSale || !req.AgreedSellerAgreement {
|
||
return nil, ErrAgreementRequired
|
||
}
|
||
rules, err := s.publishRules(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := validateRequest(req, rules); err != nil {
|
||
return nil, err
|
||
}
|
||
reviewRequired, err := s.reviewRequired(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.repo.Create(ownerID, req, reviewRequired)
|
||
}
|
||
|
||
func (s *Service) ImportExternalUpload(ctx context.Context, 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(ctx)
|
||
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,
|
||
ListingNo: dto.ListingNo,
|
||
AccountID: dto.AccountID,
|
||
Status: dto.Status,
|
||
ReviewStatus: dto.ReviewStatus,
|
||
}
|
||
results = append(results, result)
|
||
if len(items) == 1 {
|
||
resp.ListingID = dto.ID
|
||
resp.ListingNo = dto.ListingNo
|
||
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(ctx context.Context, ownerID uint64, id uint64, req UpdateRequest) (*ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
rules, err := s.publishRules(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := validateRequest(req, rules); err != nil {
|
||
return nil, err
|
||
}
|
||
reviewRequired, err := s.reviewRequired(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.repo.Update(ownerID, id, req, reviewRequired)
|
||
}
|
||
|
||
func (s *Service) SubmitReview(ctx context.Context, ownerID uint64, id uint64) (*ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
reviewRequired, err := s.reviewRequired(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.repo.SubmitReview(ownerID, id, reviewRequired)
|
||
}
|
||
|
||
func (s *Service) ListPendingReview() ([]ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
return s.repo.ListPendingReview()
|
||
}
|
||
|
||
func (s *Service) ListAdmin(query AdminListQuery) (*AdminListResult, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
return s.repo.ListAdmin(query)
|
||
}
|
||
|
||
func (s *Service) FindAdmin(id uint64) (*ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
return s.repo.FindAdmin(id)
|
||
}
|
||
|
||
func (s *Service) AdminOffline(adminID uint64, id uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
if req.Reason == "" {
|
||
return nil, ErrInvalidInput
|
||
}
|
||
return s.repo.AdminOffline(adminID, id, req, meta)
|
||
}
|
||
|
||
func (s *Service) AdminMarkAbnormal(adminID uint64, id uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
if req.Reason == "" {
|
||
return nil, ErrInvalidInput
|
||
}
|
||
return s.repo.AdminMarkAbnormal(adminID, id, req, meta)
|
||
}
|
||
|
||
func (s *Service) Approve(id uint64) (*ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
return s.repo.Approve(id)
|
||
}
|
||
|
||
func (s *Service) AdjustReviewPrice(adminID uint64, id uint64, req AdminPriceAdjustRequest, meta AuditMeta) (*ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
if req.BuyerRatio <= 0 && req.BuyerTotalPriceCent <= 0 {
|
||
return nil, ErrInvalidPrice
|
||
}
|
||
return s.repo.AdjustReviewPrice(adminID, id, req, meta)
|
||
}
|
||
|
||
func (s *Service) Reject(id uint64, req ReviewRequest) (*ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
if req.Reason == "" {
|
||
return nil, ErrInvalidInput
|
||
}
|
||
return s.repo.Reject(id, req)
|
||
}
|
||
|
||
func (s *Service) Offline(ownerID uint64, id uint64) (*ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
return s.repo.Offline(ownerID, id)
|
||
}
|
||
|
||
func (s *Service) ListPublic(query PublicListQuery) (*PublicListResult, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
return s.repo.ListPublic(query)
|
||
}
|
||
|
||
func (s *Service) ListMine(ownerID uint64) ([]ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
return s.repo.ListMine(ownerID)
|
||
}
|
||
|
||
func (s *Service) FindPublic(id uint64) (*ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
return s.repo.FindPublic(id)
|
||
}
|
||
|
||
func (s *Service) FindPublicCoverKey(id uint64) (string, error) {
|
||
if s.repo == nil {
|
||
return "", ErrDependencyUnavailable
|
||
}
|
||
return s.repo.FindPublicCoverKey(id)
|
||
}
|
||
|
||
func (s *Service) FindPublicScreenshotKey(id uint64, index int) (string, error) {
|
||
if s.repo == nil {
|
||
return "", ErrDependencyUnavailable
|
||
}
|
||
return s.repo.FindPublicScreenshotKey(id, index)
|
||
}
|
||
|
||
func (s *Service) FindMine(ownerID uint64, id uint64) (*ListingDTO, error) {
|
||
if s.repo == nil {
|
||
return nil, ErrDependencyUnavailable
|
||
}
|
||
return s.repo.FindMine(ownerID, id)
|
||
}
|
||
|
||
type publishRules struct {
|
||
FireLevelMin int
|
||
}
|
||
|
||
func validateRequest(req CreateRequest, rules publishRules) error {
|
||
if strings.TrimSpace(req.Title) == "" {
|
||
return ErrMissingTitle
|
||
}
|
||
if strings.TrimSpace(req.ServerRegion) == "" {
|
||
return ErrMissingServerRegion
|
||
}
|
||
if normalizedListingPriceCent(req) <= 0 {
|
||
return ErrInvalidPrice
|
||
}
|
||
if req.DepositAmountCent < 0 {
|
||
return ErrInvalidDeposit
|
||
}
|
||
if consumables := consumableValue(req.AssetSummary); consumables > 0 && req.DepositAmountCent <= yuanToCent(consumables) {
|
||
return ErrDepositTooLow
|
||
}
|
||
if req.HafCoinAmount < 0 {
|
||
return ErrInvalidHafCoin
|
||
}
|
||
if !hasScreenshotURL(req.ScreenshotURLS) {
|
||
return ErrMissingScreenshot
|
||
}
|
||
if fireLevel, ok := readFireLevel(req.AssetSummary); ok && fireLevel < rules.FireLevelMin {
|
||
return FireLevelTooLowError{Min: rules.FireLevelMin}
|
||
}
|
||
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},
|
||
PriceCent: yuanToCent(price),
|
||
DepositAmountCent: yuanToCent(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
|
||
}
|
||
rawResources, ok := summary["resources"]
|
||
if !ok {
|
||
return 0
|
||
}
|
||
resources, ok := rawResources.([]any)
|
||
if !ok {
|
||
return 0
|
||
}
|
||
total := 0.0
|
||
for _, raw := range resources {
|
||
resource, ok := raw.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
mode, _ := resource["mode"].(string)
|
||
if strings.TrimSpace(mode) != "收费" {
|
||
continue
|
||
}
|
||
quantity := readSummaryFloat(resource["quantity"])
|
||
if quantity <= 0 {
|
||
continue
|
||
}
|
||
priceText, _ := resource["price"].(string)
|
||
total += quantity * readUnitPrice(priceText)
|
||
}
|
||
return roundMoney(total)
|
||
}
|
||
|
||
func readSummaryFloat(value any) float64 {
|
||
switch current := value.(type) {
|
||
case float64:
|
||
return current
|
||
case int:
|
||
return float64(current)
|
||
case int64:
|
||
return float64(current)
|
||
case json.Number:
|
||
parsed, err := current.Float64()
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return parsed
|
||
case string:
|
||
parsed, err := strconv.ParseFloat(strings.TrimSpace(current), 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return parsed
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func readUnitPrice(priceText string) float64 {
|
||
numbers := priceNumberPattern.FindAllString(priceText, -1)
|
||
if len(numbers) == 0 {
|
||
return 0
|
||
}
|
||
amount, err := strconv.ParseFloat(numbers[0], 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
if len(numbers) >= 2 {
|
||
count, err := strconv.ParseFloat(numbers[1], 64)
|
||
if err == nil && count > 0 {
|
||
return amount / count
|
||
}
|
||
}
|
||
return amount
|
||
}
|
||
|
||
func readFireLevel(summary map[string]any) (int, bool) {
|
||
if summary == nil {
|
||
return 0, false
|
||
}
|
||
value, ok := summary["fire_level"]
|
||
if !ok {
|
||
return 0, false
|
||
}
|
||
switch current := value.(type) {
|
||
case float64:
|
||
return int(current), true
|
||
case int:
|
||
return current, true
|
||
case string:
|
||
parsed, err := strconv.Atoi(strings.TrimSpace(current))
|
||
return parsed, err == nil
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|
||
|
||
func hasScreenshotURL(urls []string) bool {
|
||
for _, url := range urls {
|
||
if strings.TrimSpace(url) != "" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s *Service) reviewRequired(ctx context.Context) (bool, error) {
|
||
if s.config == nil {
|
||
return false, nil
|
||
}
|
||
value, err := s.config.FindValue(ctx, reviewRequiredConfigKey)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
required, err := strconv.ParseBool(strings.TrimSpace(value))
|
||
if err != nil {
|
||
return false, nil
|
||
}
|
||
return required, nil
|
||
}
|
||
|
||
func (s *Service) publishRules(ctx context.Context) (publishRules, error) {
|
||
rules := publishRules{FireLevelMin: defaultFireLevelMin}
|
||
if s.config == nil {
|
||
return rules, nil
|
||
}
|
||
value, err := s.config.FindValue(ctx, publishOptionsConfigKey)
|
||
if err != nil {
|
||
return rules, err
|
||
}
|
||
var raw struct {
|
||
FireLevelMin int `json:"fire_level_min"`
|
||
}
|
||
if err := json.Unmarshal([]byte(value), &raw); err != nil {
|
||
return rules, nil
|
||
}
|
||
if raw.FireLevelMin > 0 {
|
||
rules.FireLevelMin = raw.FireLevelMin
|
||
}
|
||
return rules, nil
|
||
}
|