diff --git a/backend/internal/modules/listing/external_upload.go b/backend/internal/modules/listing/external_upload.go new file mode 100644 index 0000000..48f5bac --- /dev/null +++ b/backend/internal/modules/listing/external_upload.go @@ -0,0 +1,383 @@ +package listing + +import ( + "context" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" +) + +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(ctx, 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 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 +} diff --git a/backend/internal/modules/listing/handler.go b/backend/internal/modules/listing/handler.go index c3b59db..a9c44a1 100644 --- a/backend/internal/modules/listing/handler.go +++ b/backend/internal/modules/listing/handler.go @@ -1,19 +1,7 @@ package listing import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "strconv" - "strings" - - "hfb_sys/backend/internal/middleware" filemodule "hfb_sys/backend/internal/modules/file" - "hfb_sys/backend/pkg/response" - - "github.com/gin-gonic/gin" ) type Handler struct { @@ -26,594 +14,3 @@ const maxExternalUploadBodyBytes = 256 * 1024 func NewHandler(service *Service, storage *filemodule.Storage) *Handler { return &Handler{service: service, storage: storage} } - -func (h *Handler) Create(c *gin.Context) { - ownerID, ok := currentUserID(c) - if !ok { - response.Unauthorized(c, "缺少用户上下文") - return - } - var req CreateRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "发布信息不完整") - return - } - item, err := h.service.Create(c.Request.Context(), ownerID, req) - if err != nil { - writeListingError(c, err) - return - } - 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(c.Request.Context(), 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 { - response.Unauthorized(c, "缺少用户上下文") - return - } - id, ok := parseID(c) - if !ok { - return - } - var req UpdateRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "发布信息不完整") - return - } - item, err := h.service.Update(c.Request.Context(), ownerID, id, req) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, item) -} - -func (h *Handler) SubmitReview(c *gin.Context) { - ownerID, ok := currentUserID(c) - if !ok { - response.Unauthorized(c, "缺少用户上下文") - return - } - id, ok := parseID(c) - if !ok { - return - } - item, err := h.service.SubmitReview(c.Request.Context(), ownerID, id) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, item) -} - -func (h *Handler) ListPendingReview(c *gin.Context) { - items, err := h.service.ListPendingReview(c.Request.Context()) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, gin.H{"items": items}) -} - -func (h *Handler) ListAdmin(c *gin.Context) { - query, ok := parseAdminListQuery(c) - if !ok { - return - } - result, err := h.service.ListAdmin(c.Request.Context(), query) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, result) -} - -func (h *Handler) FindAdmin(c *gin.Context) { - id, ok := parseID(c) - if !ok { - return - } - item, err := h.service.FindAdmin(c.Request.Context(), id) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, item) -} - -func (h *Handler) AdminOffline(c *gin.Context) { - h.adminAction(c, h.service.AdminOffline) -} - -func (h *Handler) AdminMarkAbnormal(c *gin.Context) { - h.adminAction(c, h.service.AdminMarkAbnormal) -} - -func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) (*ListingDTO, error)) { - adminID, ok := currentAdminID(c) - if !ok { - response.Unauthorized(c, "缺少管理员上下文") - return - } - id, ok := parseID(c) - if !ok { - return - } - var req AdminActionRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "操作原因不能为空") - return - } - item, err := fn(c.Request.Context(), adminID, id, req, auditMeta(c)) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, item) -} - -func auditMeta(c *gin.Context) AuditMeta { - return AuditMeta{ - IP: c.ClientIP(), - UserAgent: c.GetHeader("User-Agent"), - RequestID: middleware.GetRequestID(c), - } -} - -func (h *Handler) Approve(c *gin.Context) { - id, ok := parseID(c) - if !ok { - return - } - item, err := h.service.Approve(c.Request.Context(), id) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, item) -} - -func (h *Handler) AdjustReviewPrice(c *gin.Context) { - adminID, ok := currentAdminID(c) - if !ok { - response.Unauthorized(c, "缺少管理员上下文") - return - } - id, ok := parseID(c) - if !ok { - return - } - var req AdminPriceAdjustRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "调价参数不正确") - return - } - item, err := h.service.AdjustReviewPrice(c.Request.Context(), adminID, id, req, auditMeta(c)) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, item) -} - -func (h *Handler) Reject(c *gin.Context) { - id, ok := parseID(c) - if !ok { - return - } - var req ReviewRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "审核拒绝原因不能为空") - return - } - item, err := h.service.Reject(c.Request.Context(), id, req) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, item) -} - -func (h *Handler) Offline(c *gin.Context) { - ownerID, ok := currentUserID(c) - if !ok { - response.Unauthorized(c, "缺少用户上下文") - return - } - id, ok := parseID(c) - if !ok { - return - } - item, err := h.service.Offline(c.Request.Context(), ownerID, id) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, item) -} - -func (h *Handler) ListPublic(c *gin.Context) { - items, err := h.service.ListPublic(c.Request.Context(), parsePublicListQuery(c)) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, items) -} - -func (h *Handler) FindPublic(c *gin.Context) { - id, ok := parseID(c) - if !ok { - return - } - item, err := h.service.FindPublic(c.Request.Context(), id) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, item) -} - -func (h *Handler) Cover(c *gin.Context) { - id, ok := parseID(c) - if !ok { - return - } - key, err := h.service.FindPublicCoverKey(c.Request.Context(), id) - if err != nil { - writeListingError(c, err) - return - } - h.writePublicObject(c, filemodule.ImageVariantFallbackKeys(key, filemodule.ImageVariantThumb)...) -} - -func (h *Handler) Screenshot(c *gin.Context) { - id, ok := parseID(c) - if !ok { - return - } - index, err := strconv.Atoi(c.Param("index")) - if err != nil || index < 0 { - response.BadRequest(c, "截图序号不正确") - return - } - key, err := h.service.FindPublicScreenshotKey(c.Request.Context(), id, index) - if err != nil { - writeListingError(c, err) - return - } - h.writePublicObject(c, filemodule.ImageVariantFallbackKeys(key, filemodule.ImageVariantMedium)...) -} - -func (h *Handler) writePublicObject(c *gin.Context, keys ...string) { - if h.storage == nil { - response.ServiceUnavailable(c, "文件存储未连接") - return - } - var object *filemodule.Object - for _, key := range keys { - nextObject, err := h.storage.Get(c.Request.Context(), key) - if err == nil { - object = nextObject - break - } - } - if object == nil { - response.Error(c, http.StatusNotFound, "not_found", "图片不存在或暂不可访问") - return - } - defer func() { - _ = object.Reader.Close() - }() - contentType := object.ContentType - if contentType == "" { - contentType = "application/octet-stream" - } - c.Header("Content-Type", contentType) - c.Header("Cache-Control", "public, max-age=300") - c.DataFromReader(http.StatusOK, object.Size, contentType, object.Reader, nil) -} - -func (h *Handler) ListMine(c *gin.Context) { - ownerID, ok := currentUserID(c) - if !ok { - response.Unauthorized(c, "缺少用户上下文") - return - } - items, err := h.service.ListMine(c.Request.Context(), ownerID) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, gin.H{"items": items}) -} - -func (h *Handler) FindMine(c *gin.Context) { - ownerID, ok := currentUserID(c) - if !ok { - response.Unauthorized(c, "缺少用户上下文") - return - } - id, ok := parseID(c) - if !ok { - return - } - item, err := h.service.FindMine(c.Request.Context(), ownerID, id) - if err != nil { - writeListingError(c, err) - return - } - response.OK(c, item) -} - -func currentUserID(c *gin.Context) (uint64, bool) { - value, ok := c.Get(middleware.ContextUserID) - if !ok { - return 0, false - } - userID, ok := value.(uint64) - return userID, ok -} - -func currentAdminID(c *gin.Context) (uint64, bool) { - value, ok := c.Get(middleware.ContextAdminID) - if !ok { - return 0, false - } - adminID, ok := value.(uint64) - return adminID, ok -} - -func parseID(c *gin.Context) (uint64, bool) { - id, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil || id == 0 { - response.BadRequest(c, "ID 不正确") - return 0, false - } - return id, true -} - -func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) { - var query AdminListQuery - if raw := c.Query("owner_id"); raw != "" { - value, err := strconv.ParseUint(raw, 10, 64) - if err != nil || value == 0 { - response.BadRequest(c, "号主 ID 不正确") - return query, false - } - query.OwnerID = value - } - if raw := c.Query("limit"); raw != "" { - value, err := strconv.Atoi(raw) - if err != nil || value <= 0 { - response.BadRequest(c, "查询条数不正确") - return query, false - } - query.Limit = value - } - if raw := c.Query("page"); raw != "" { - value, err := strconv.Atoi(raw) - if err != nil || value <= 0 { - response.BadRequest(c, "页码不正确") - return query, false - } - query.Page = value - } - if raw := c.Query("page_size"); raw != "" { - value, err := strconv.Atoi(raw) - if err != nil || value <= 0 { - response.BadRequest(c, "每页条数不正确") - return query, false - } - query.PageSize = value - } - query.Status = c.Query("status") - query.ReviewStatus = c.Query("review_status") - return query, true -} - -func parsePublicListQuery(c *gin.Context) PublicListQuery { - query := PublicListQuery{ - Page: parsePositiveInt(c.DefaultQuery("page", "1"), 1), - PageSize: parsePositiveInt(c.DefaultQuery("page_size", "20"), 20), - Keyword: strings.TrimSpace(c.Query("keyword")), - Sort: strings.TrimSpace(c.Query("sort")), - Zone: strings.TrimSpace(c.Query("zone")), - Server: parseCSVQuery(c.Query("server")), - Region: parseCSVQuery(c.Query("region")), - LoginMethod: parseCSVQuery(firstNonEmpty(c.Query("login_method"), c.Query("login"))), - Rank: parseCSVQuery(c.Query("rank")), - Insurance: parseCSVQuery(c.Query("insurance")), - Stamina: parseCSVQuery(c.Query("stamina")), - Load: parseCSVQuery(c.Query("load")), - SkinGroup: parseCSVQuery(c.Query("skin_group")), - SkinName: parseCSVQuery(firstNonEmpty(c.Query("skin_name"), c.Query("skin"))), - MinCoin: parseOptionalFloat(c.Query("min_coin")), - MaxCoin: parseOptionalFloat(c.Query("max_coin")), - MinPrice: parseOptionalFloat(c.Query("min_price")), - MaxPrice: parseOptionalFloat(c.Query("max_price")), - MinDeposit: parseOptionalFloat(c.Query("min_deposit")), - MaxDeposit: parseOptionalFloat(c.Query("max_deposit")), - MinTotal: parseOptionalFloat(c.Query("min_total")), - MaxTotal: parseOptionalFloat(c.Query("max_total")), - MinFireLevel: parseOptionalFloat(c.Query("min_fire_level")), - MaxFireLevel: parseOptionalFloat(c.Query("max_fire_level")), - MinSecretKD: parseOptionalFloat(c.Query("min_secret_kd")), - MaxSecretKD: parseOptionalFloat(c.Query("max_secret_kd")), - ResourceRanges: parseResourceRanges(c), - } - if query.PageSize > 50 { - query.PageSize = 50 - } - return query -} - -func parsePositiveInt(value string, fallback int) int { - parsed, err := strconv.Atoi(strings.TrimSpace(value)) - if err != nil || parsed <= 0 { - return fallback - } - return parsed -} - -func parseOptionalFloat(value string) *float64 { - value = strings.TrimSpace(value) - if value == "" { - return nil - } - parsed, err := strconv.ParseFloat(value, 64) - if err != nil { - return nil - } - return &parsed -} - -func parseCSVQuery(value string) []string { - parts := strings.Split(value, ",") - result := make([]string, 0, len(parts)) - seen := make(map[string]struct{}, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if _, ok := seen[part]; ok { - continue - } - seen[part] = struct{}{} - result = append(result, part) - } - return result -} - -func parseResourceRanges(c *gin.Context) map[string]NumberRange { - ranges := make(map[string]NumberRange) - for key, values := range c.Request.URL.Query() { - if !strings.HasPrefix(key, "resource_") { - continue - } - var resourceKey string - var isMin bool - switch { - case strings.HasSuffix(key, "_min"): - resourceKey = strings.TrimSuffix(strings.TrimPrefix(key, "resource_"), "_min") - isMin = true - case strings.HasSuffix(key, "_max"): - resourceKey = strings.TrimSuffix(strings.TrimPrefix(key, "resource_"), "_max") - default: - continue - } - if resourceKey == "" || len(values) == 0 { - continue - } - value := parseOptionalFloat(values[0]) - if value == nil { - continue - } - numberRange := ranges[resourceKey] - if isMin { - numberRange.Min = value - } else { - numberRange.Max = value - } - ranges[resourceKey] = numberRange - } - return ranges -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return value - } - } - return "" -} - -func writeListingError(c *gin.Context, err error) { - switch { - case errors.Is(err, ErrDependencyUnavailable): - response.ServiceUnavailable(c, "数据库未连接") - case errors.Is(err, ErrMissingTitle): - response.BadRequest(c, "发布标题不能为空") - case errors.Is(err, ErrMissingServerRegion): - response.BadRequest(c, "请选择区服") - case errors.Is(err, ErrInvalidPrice): - response.BadRequest(c, "发布价格不正确") - case errors.Is(err, ErrInvalidDeposit): - response.BadRequest(c, "押金不能小于 0") - case errors.Is(err, ErrDepositTooLow): - response.BadRequest(c, "押金必须大于额外消耗品总价值") - case errors.Is(err, ErrInvalidHafCoin): - response.BadRequest(c, "哈夫币数量不正确") - case errors.Is(err, ErrMissingScreenshot): - response.BadRequest(c, "请至少上传一张账号截图") - case errors.Is(err, ErrAgreementRequired): - 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) - response.BadRequest(c, "烽火等级低于"+strconv.Itoa(levelErr.Min)+"级的号无法发布") - case errors.Is(err, ErrInvalidInput): - response.BadRequest(c, "发布信息不符合规则") - case errors.Is(err, ErrListingLocked): - response.Error(c, http.StatusConflict, "listing_locked", "当前发布不可修改") - case IsNotFound(err): - response.Error(c, http.StatusNotFound, "not_found", "发布不存在") - default: - response.Error(c, http.StatusInternalServerError, "internal_error", "发布服务暂时不可用") - } -} - -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/handler_admin.go b/backend/internal/modules/listing/handler_admin.go new file mode 100644 index 0000000..03dd6af --- /dev/null +++ b/backend/internal/modules/listing/handler_admin.go @@ -0,0 +1,138 @@ +package listing + +import ( + "context" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) ListPendingReview(c *gin.Context) { + items, err := h.service.ListPendingReview(c.Request.Context()) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *Handler) ListAdmin(c *gin.Context) { + query, ok := parseAdminListQuery(c) + if !ok { + return + } + result, err := h.service.ListAdmin(c.Request.Context(), query) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, result) +} + +func (h *Handler) FindAdmin(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.FindAdmin(c.Request.Context(), id) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) AdminOffline(c *gin.Context) { + h.adminAction(c, h.service.AdminOffline) +} + +func (h *Handler) AdminMarkAbnormal(c *gin.Context) { + h.adminAction(c, h.service.AdminMarkAbnormal) +} + +func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) (*ListingDTO, error)) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req AdminActionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "操作原因不能为空") + return + } + item, err := fn(c.Request.Context(), adminID, id, req, auditMeta(c)) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func auditMeta(c *gin.Context) AuditMeta { + return AuditMeta{ + IP: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + RequestID: middleware.GetRequestID(c), + } +} + +func (h *Handler) Approve(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.Approve(c.Request.Context(), id) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) AdjustReviewPrice(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req AdminPriceAdjustRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "调价参数不正确") + return + } + item, err := h.service.AdjustReviewPrice(c.Request.Context(), adminID, id, req, auditMeta(c)) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Reject(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req ReviewRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "审核拒绝原因不能为空") + return + } + item, err := h.service.Reject(c.Request.Context(), id, req) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} diff --git a/backend/internal/modules/listing/handler_error.go b/backend/internal/modules/listing/handler_error.go new file mode 100644 index 0000000..094b1ce --- /dev/null +++ b/backend/internal/modules/listing/handler_error.go @@ -0,0 +1,68 @@ +package listing + +import ( + "errors" + "net/http" + "strconv" + + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func writeListingError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrMissingTitle): + response.BadRequest(c, "发布标题不能为空") + case errors.Is(err, ErrMissingServerRegion): + response.BadRequest(c, "请选择区服") + case errors.Is(err, ErrInvalidPrice): + response.BadRequest(c, "发布价格不正确") + case errors.Is(err, ErrInvalidDeposit): + response.BadRequest(c, "押金不能小于 0") + case errors.Is(err, ErrDepositTooLow): + response.BadRequest(c, "押金必须大于额外消耗品总价值") + case errors.Is(err, ErrInvalidHafCoin): + response.BadRequest(c, "哈夫币数量不正确") + case errors.Is(err, ErrMissingScreenshot): + response.BadRequest(c, "请至少上传一张账号截图") + case errors.Is(err, ErrAgreementRequired): + 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) + response.BadRequest(c, "烽火等级低于"+strconv.Itoa(levelErr.Min)+"级的号无法发布") + case errors.Is(err, ErrInvalidInput): + response.BadRequest(c, "发布信息不符合规则") + case errors.Is(err, ErrListingLocked): + response.Error(c, http.StatusConflict, "listing_locked", "当前发布不可修改") + case IsNotFound(err): + response.Error(c, http.StatusNotFound, "not_found", "发布不存在") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "发布服务暂时不可用") + } +} + +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/handler_external.go b/backend/internal/modules/listing/handler_external.go new file mode 100644 index 0000000..b57561b --- /dev/null +++ b/backend/internal/modules/listing/handler_external.go @@ -0,0 +1,41 @@ +package listing + +import ( + "encoding/json" + "io" + "net/http" + + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +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(c.Request.Context(), 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, `账号资料默认图开放接口上传,待后台补充审核`) +} diff --git a/backend/internal/modules/listing/handler_parse.go b/backend/internal/modules/listing/handler_parse.go new file mode 100644 index 0000000..853baf3 --- /dev/null +++ b/backend/internal/modules/listing/handler_parse.go @@ -0,0 +1,195 @@ +package listing + +import ( + "strconv" + "strings" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func currentUserID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextUserID) + if !ok { + return 0, false + } + userID, ok := value.(uint64) + return userID, ok +} + +func currentAdminID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextAdminID) + if !ok { + return 0, false + } + adminID, ok := value.(uint64) + return adminID, ok +} + +func parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 不正确") + return 0, false + } + return id, true +} + +func parseAdminListQuery(c *gin.Context) (AdminListQuery, bool) { + var query AdminListQuery + if raw := c.Query("owner_id"); raw != "" { + value, err := strconv.ParseUint(raw, 10, 64) + if err != nil || value == 0 { + response.BadRequest(c, "号主 ID 不正确") + return query, false + } + query.OwnerID = value + } + if raw := c.Query("limit"); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + response.BadRequest(c, "查询条数不正确") + return query, false + } + query.Limit = value + } + if raw := c.Query("page"); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + response.BadRequest(c, "页码不正确") + return query, false + } + query.Page = value + } + if raw := c.Query("page_size"); raw != "" { + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + response.BadRequest(c, "每页条数不正确") + return query, false + } + query.PageSize = value + } + query.Status = c.Query("status") + query.ReviewStatus = c.Query("review_status") + return query, true +} + +func parsePublicListQuery(c *gin.Context) PublicListQuery { + query := PublicListQuery{ + Page: parsePositiveInt(c.DefaultQuery("page", "1"), 1), + PageSize: parsePositiveInt(c.DefaultQuery("page_size", "20"), 20), + Keyword: strings.TrimSpace(c.Query("keyword")), + Sort: strings.TrimSpace(c.Query("sort")), + Zone: strings.TrimSpace(c.Query("zone")), + Server: parseCSVQuery(c.Query("server")), + Region: parseCSVQuery(c.Query("region")), + LoginMethod: parseCSVQuery(firstNonEmpty(c.Query("login_method"), c.Query("login"))), + Rank: parseCSVQuery(c.Query("rank")), + Insurance: parseCSVQuery(c.Query("insurance")), + Stamina: parseCSVQuery(c.Query("stamina")), + Load: parseCSVQuery(c.Query("load")), + SkinGroup: parseCSVQuery(c.Query("skin_group")), + SkinName: parseCSVQuery(firstNonEmpty(c.Query("skin_name"), c.Query("skin"))), + MinCoin: parseOptionalFloat(c.Query("min_coin")), + MaxCoin: parseOptionalFloat(c.Query("max_coin")), + MinPrice: parseOptionalFloat(c.Query("min_price")), + MaxPrice: parseOptionalFloat(c.Query("max_price")), + MinDeposit: parseOptionalFloat(c.Query("min_deposit")), + MaxDeposit: parseOptionalFloat(c.Query("max_deposit")), + MinTotal: parseOptionalFloat(c.Query("min_total")), + MaxTotal: parseOptionalFloat(c.Query("max_total")), + MinFireLevel: parseOptionalFloat(c.Query("min_fire_level")), + MaxFireLevel: parseOptionalFloat(c.Query("max_fire_level")), + MinSecretKD: parseOptionalFloat(c.Query("min_secret_kd")), + MaxSecretKD: parseOptionalFloat(c.Query("max_secret_kd")), + ResourceRanges: parseResourceRanges(c), + } + if query.PageSize > 50 { + query.PageSize = 50 + } + return query +} + +func parsePositiveInt(value string, fallback int) int { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +func parseOptionalFloat(value string) *float64 { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return nil + } + return &parsed +} + +func parseCSVQuery(value string) []string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, ok := seen[part]; ok { + continue + } + seen[part] = struct{}{} + result = append(result, part) + } + return result +} + +func parseResourceRanges(c *gin.Context) map[string]NumberRange { + ranges := make(map[string]NumberRange) + for key, values := range c.Request.URL.Query() { + if !strings.HasPrefix(key, "resource_") { + continue + } + var resourceKey string + var isMin bool + switch { + case strings.HasSuffix(key, "_min"): + resourceKey = strings.TrimSuffix(strings.TrimPrefix(key, "resource_"), "_min") + isMin = true + case strings.HasSuffix(key, "_max"): + resourceKey = strings.TrimSuffix(strings.TrimPrefix(key, "resource_"), "_max") + default: + continue + } + if resourceKey == "" || len(values) == 0 { + continue + } + value := parseOptionalFloat(values[0]) + if value == nil { + continue + } + numberRange := ranges[resourceKey] + if isMin { + numberRange.Min = value + } else { + numberRange.Max = value + } + ranges[resourceKey] = numberRange + } + return ranges +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/backend/internal/modules/listing/handler_public.go b/backend/internal/modules/listing/handler_public.go new file mode 100644 index 0000000..b4955ff --- /dev/null +++ b/backend/internal/modules/listing/handler_public.go @@ -0,0 +1,93 @@ +package listing + +import ( + "net/http" + "strconv" + + filemodule "hfb_sys/backend/internal/modules/file" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) ListPublic(c *gin.Context) { + items, err := h.service.ListPublic(c.Request.Context(), parsePublicListQuery(c)) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, items) +} + +func (h *Handler) FindPublic(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.FindPublic(c.Request.Context(), id) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Cover(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + key, err := h.service.FindPublicCoverKey(c.Request.Context(), id) + if err != nil { + writeListingError(c, err) + return + } + h.writePublicObject(c, filemodule.ImageVariantFallbackKeys(key, filemodule.ImageVariantThumb)...) +} + +func (h *Handler) Screenshot(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + index, err := strconv.Atoi(c.Param("index")) + if err != nil || index < 0 { + response.BadRequest(c, "截图序号不正确") + return + } + key, err := h.service.FindPublicScreenshotKey(c.Request.Context(), id, index) + if err != nil { + writeListingError(c, err) + return + } + h.writePublicObject(c, filemodule.ImageVariantFallbackKeys(key, filemodule.ImageVariantMedium)...) +} + +func (h *Handler) writePublicObject(c *gin.Context, keys ...string) { + if h.storage == nil { + response.ServiceUnavailable(c, "文件存储未连接") + return + } + var object *filemodule.Object + for _, key := range keys { + nextObject, err := h.storage.Get(c.Request.Context(), key) + if err == nil { + object = nextObject + break + } + } + if object == nil { + response.Error(c, http.StatusNotFound, "not_found", "图片不存在或暂不可访问") + return + } + defer func() { + _ = object.Reader.Close() + }() + contentType := object.ContentType + if contentType == "" { + contentType = "application/octet-stream" + } + c.Header("Content-Type", contentType) + c.Header("Cache-Control", "public, max-age=300") + c.DataFromReader(http.StatusOK, object.Size, contentType, object.Reader, nil) +} diff --git a/backend/internal/modules/listing/handler_user.go b/backend/internal/modules/listing/handler_user.go new file mode 100644 index 0000000..67eb1ba --- /dev/null +++ b/backend/internal/modules/listing/handler_user.go @@ -0,0 +1,117 @@ +package listing + +import ( + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) Create(c *gin.Context) { + ownerID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + var req CreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "发布信息不完整") + return + } + item, err := h.service.Create(c.Request.Context(), ownerID, req) + if err != nil { + writeListingError(c, err) + return + } + response.Created(c, item) +} + +func (h *Handler) Update(c *gin.Context) { + ownerID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req UpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "发布信息不完整") + return + } + item, err := h.service.Update(c.Request.Context(), ownerID, id, req) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) SubmitReview(c *gin.Context) { + ownerID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.SubmitReview(c.Request.Context(), ownerID, id) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Offline(c *gin.Context) { + ownerID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.Offline(c.Request.Context(), ownerID, id) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) ListMine(c *gin.Context) { + ownerID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + items, err := h.service.ListMine(c.Request.Context(), ownerID) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *Handler) FindMine(c *gin.Context) { + ownerID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.FindMine(c.Request.Context(), ownerID, id) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index b016194..3c466b3 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -2,14 +2,8 @@ package listing import ( "context" - "encoding/json" "errors" - "fmt" - "math" - "regexp" - "strconv" "strings" - "time" ) var ( @@ -48,8 +42,6 @@ const ( defaultUploadScreenshot = "/api/listings/default-upload-screenshot" ) -var priceNumberPattern = regexp.MustCompile(`(\d+(?:\.\d+)?)`) - type FireLevelTooLowError struct { Min int } @@ -77,715 +69,3 @@ func (e UploadValidationError) Error() string { 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(ctx, 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(ctx, 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(ctx, 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(ctx, ownerID, id, reviewRequired) -} - -func (s *Service) ListPendingReview(ctx context.Context) ([]ListingDTO, error) { - if s.repo == nil { - return nil, ErrDependencyUnavailable - } - return s.repo.ListPendingReview(ctx) -} - -func (s *Service) ListAdmin(ctx context.Context, query AdminListQuery) (*AdminListResult, error) { - if s.repo == nil { - return nil, ErrDependencyUnavailable - } - return s.repo.ListAdmin(ctx, query) -} - -func (s *Service) FindAdmin(ctx context.Context, id uint64) (*ListingDTO, error) { - if s.repo == nil { - return nil, ErrDependencyUnavailable - } - return s.repo.FindAdmin(ctx, id) -} - -func (s *Service) AdminOffline(ctx context.Context, 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(ctx, adminID, id, req, meta) -} - -func (s *Service) AdminMarkAbnormal(ctx context.Context, 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(ctx, adminID, id, req, meta) -} - -func (s *Service) Approve(ctx context.Context, id uint64) (*ListingDTO, error) { - if s.repo == nil { - return nil, ErrDependencyUnavailable - } - return s.repo.Approve(ctx, id) -} - -func (s *Service) AdjustReviewPrice(ctx context.Context, 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(ctx, adminID, id, req, meta) -} - -func (s *Service) Reject(ctx context.Context, id uint64, req ReviewRequest) (*ListingDTO, error) { - if s.repo == nil { - return nil, ErrDependencyUnavailable - } - if req.Reason == "" { - return nil, ErrInvalidInput - } - return s.repo.Reject(ctx, id, req) -} - -func (s *Service) Offline(ctx context.Context, ownerID uint64, id uint64) (*ListingDTO, error) { - if s.repo == nil { - return nil, ErrDependencyUnavailable - } - return s.repo.Offline(ctx, ownerID, id) -} - -func (s *Service) ListPublic(ctx context.Context, query PublicListQuery) (*PublicListResult, error) { - if s.repo == nil { - return nil, ErrDependencyUnavailable - } - return s.repo.ListPublic(ctx, query) -} - -func (s *Service) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, error) { - if s.repo == nil { - return nil, ErrDependencyUnavailable - } - return s.repo.ListMine(ctx, ownerID) -} - -func (s *Service) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) { - if s.repo == nil { - return nil, ErrDependencyUnavailable - } - return s.repo.FindPublic(ctx, id) -} - -func (s *Service) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) { - if s.repo == nil { - return "", ErrDependencyUnavailable - } - return s.repo.FindPublicCoverKey(ctx, id) -} - -func (s *Service) FindPublicScreenshotKey(ctx context.Context, id uint64, index int) (string, error) { - if s.repo == nil { - return "", ErrDependencyUnavailable - } - return s.repo.FindPublicScreenshotKey(ctx, id, index) -} - -func (s *Service) FindMine(ctx context.Context, ownerID uint64, id uint64) (*ListingDTO, error) { - if s.repo == nil { - return nil, ErrDependencyUnavailable - } - return s.repo.FindMine(ctx, 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 -} diff --git a/backend/internal/modules/listing/service_config.go b/backend/internal/modules/listing/service_config.go new file mode 100644 index 0000000..0982ae2 --- /dev/null +++ b/backend/internal/modules/listing/service_config.go @@ -0,0 +1,44 @@ +package listing + +import ( + "context" + "encoding/json" + "strconv" + "strings" +) + +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 +} diff --git a/backend/internal/modules/listing/service_mutation.go b/backend/internal/modules/listing/service_mutation.go new file mode 100644 index 0000000..0cc2c20 --- /dev/null +++ b/backend/internal/modules/listing/service_mutation.go @@ -0,0 +1,62 @@ +package listing + +import ( + "context" +) + +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(ctx, ownerID, req, reviewRequired) +} + +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(ctx, 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(ctx, ownerID, id, reviewRequired) +} + +func (s *Service) Offline(ctx context.Context, ownerID uint64, id uint64) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Offline(ctx, ownerID, id) +} diff --git a/backend/internal/modules/listing/service_query.go b/backend/internal/modules/listing/service_query.go new file mode 100644 index 0000000..92c7bed --- /dev/null +++ b/backend/internal/modules/listing/service_query.go @@ -0,0 +1,47 @@ +package listing + +import ( + "context" +) + +func (s *Service) ListPublic(ctx context.Context, query PublicListQuery) (*PublicListResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListPublic(ctx, query) +} + +func (s *Service) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListMine(ctx, ownerID) +} + +func (s *Service) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindPublic(ctx, id) +} + +func (s *Service) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) { + if s.repo == nil { + return "", ErrDependencyUnavailable + } + return s.repo.FindPublicCoverKey(ctx, id) +} + +func (s *Service) FindPublicScreenshotKey(ctx context.Context, id uint64, index int) (string, error) { + if s.repo == nil { + return "", ErrDependencyUnavailable + } + return s.repo.FindPublicScreenshotKey(ctx, id, index) +} + +func (s *Service) FindMine(ctx context.Context, ownerID uint64, id uint64) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindMine(ctx, ownerID, id) +} diff --git a/backend/internal/modules/listing/service_review.go b/backend/internal/modules/listing/service_review.go new file mode 100644 index 0000000..2c2c654 --- /dev/null +++ b/backend/internal/modules/listing/service_review.go @@ -0,0 +1,73 @@ +package listing + +import ( + "context" +) + +func (s *Service) ListPendingReview(ctx context.Context) ([]ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListPendingReview(ctx) +} + +func (s *Service) ListAdmin(ctx context.Context, query AdminListQuery) (*AdminListResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListAdmin(ctx, query) +} + +func (s *Service) FindAdmin(ctx context.Context, id uint64) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindAdmin(ctx, id) +} + +func (s *Service) AdminOffline(ctx context.Context, 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(ctx, adminID, id, req, meta) +} + +func (s *Service) AdminMarkAbnormal(ctx context.Context, 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(ctx, adminID, id, req, meta) +} + +func (s *Service) Approve(ctx context.Context, id uint64) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Approve(ctx, id) +} + +func (s *Service) AdjustReviewPrice(ctx context.Context, 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(ctx, adminID, id, req, meta) +} + +func (s *Service) Reject(ctx context.Context, id uint64, req ReviewRequest) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.Reason == "" { + return nil, ErrInvalidInput + } + return s.repo.Reject(ctx, id, req) +} diff --git a/backend/internal/modules/listing/service_validation.go b/backend/internal/modules/listing/service_validation.go new file mode 100644 index 0000000..c678c13 --- /dev/null +++ b/backend/internal/modules/listing/service_validation.go @@ -0,0 +1,147 @@ +package listing + +import ( + "encoding/json" + "regexp" + "strconv" + "strings" +) + +var priceNumberPattern = regexp.MustCompile(`(\d+(?:\.\d+)?)`) + +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 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 +}