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
+}