拆分商品模块服务和处理器职责
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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, `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540"><rect width="960" height="540" fill="#f3f4f6"/><rect x="72" y="72" width="816" height="396" rx="24" fill="#ffffff" stroke="#d1d5db" stroke-width="4"/><text x="480" y="248" text-anchor="middle" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#374151">账号资料默认图</text><text x="480" y="310" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" fill="#6b7280">开放接口上传,待后台补充审核</text></svg>`)
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540"><rect width="960" height="540" fill="#f3f4f6"/><rect x="72" y="72" width="816" height="396" rx="24" fill="#ffffff" stroke="#d1d5db" stroke-width="4"/><text x="480" y="248" text-anchor="middle" font-family="Arial, sans-serif" font-size="42" font-weight="700" fill="#374151">账号资料默认图</text><text x="480" y="310" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" fill="#6b7280">开放接口上传,待后台补充审核</text></svg>`)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user