Files
hfb_sys/backend/internal/modules/listing/service.go
T
yml2213andClaude Opus 4.7 4ebf7f75fe refactor: 统一全项目金额精度为角(0.1元)
## 变更概述
将全项目金额处理从整元四舍五入统一为角精度(0.1元),提高金额计算准确性和显示一致性。

## 后端改动
- 新增 pkg/money/format.go 统一金额处理包
  - Round(): 角精度四舍五入
  - Min/Max(): 金额比较
  - Format(): 格式化字符串
- 更新业务模块使用统一金额函数
  - internal/modules/order: 订单结算改为角精度
  - internal/modules/dispute: 纠纷金额处理
  - internal/modules/listing: 商品定价和存储
- 更新测试用例期望值为角精度

## 前端改动
- 新增 shared/utils/money.ts 金额工具函数
  - roundMoney(): 角精度四舍五入
  - formatMoney(): 格式化为字符串(保留1位小数)
  - formatMoneyWithSymbol(): 添加¥符号
- 更新金额计算和显示逻辑
  - shared/utils/pricing.ts: 定价计算
  - shared/utils/listingDisplay.ts: 商品显示
  - shared/composables/useMoney.ts: 组合式函数
- 修复视图文件导入声明
  - features/listings/views: 商品详情页
  - features/seller/views: 卖家管理页

## 效果
- 金额显示:¥123.0(统一保留1位小数)
- 计算精度:12.45 -> 12.5(角精度)
- 减少误差:避免整元四舍五入损失
- 显示一致:全项目统一格式

## 测试
-  后端单元测试通过
-  TypeScript 类型检查通过
-  开发环境正常运行

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 16:42:49 +08:00

775 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package listing
import (
"encoding/json"
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidInput = errors.New("invalid listing input")
ErrListingLocked = errors.New("listing locked")
ErrMissingTitle = errors.New("missing listing title")
ErrMissingServerRegion = errors.New("missing server region")
ErrInvalidPrice = errors.New("invalid listing price")
ErrInvalidDeposit = errors.New("invalid listing deposit")
ErrDepositTooLow = errors.New("listing deposit too low")
ErrInvalidHafCoin = errors.New("invalid haf coin amount")
ErrMissingScreenshot = errors.New("missing screenshot")
ErrMissingUploaderName = errors.New("missing uploader name")
ErrMissingUploadData = errors.New("missing upload data")
ErrUploaderNotFound = errors.New("uploader not found")
ErrUploaderAmbiguous = errors.New("uploader ambiguous")
ErrTooManyUploadItems = errors.New("too many upload items")
)
type Service struct {
repo *Repository
config ConfigReader
}
type ConfigReader interface {
FindValue(key string) (string, error)
}
const (
reviewRequiredConfigKey = "listing.review_required"
publishOptionsConfigKey = "listing.publish_options"
defaultFireLevelMin = 38
maxExternalUploadItems = 10
defaultUploadScreenshot = "/api/listings/default-upload-screenshot"
)
var priceNumberPattern = regexp.MustCompile(`(\d+(?:\.\d+)?)`)
type FireLevelTooLowError struct {
Min int
}
func (e FireLevelTooLowError) Error() string {
return "fire level too low"
}
type UploadValidationError struct {
Missing []string
Invalid []string
}
func (e UploadValidationError) Error() string {
parts := make([]string, 0, 2)
if len(e.Missing) > 0 {
parts = append(parts, "缺少必填字段:"+strings.Join(e.Missing, "、"))
}
if len(e.Invalid) > 0 {
parts = append(parts, "字段格式不正确:"+strings.Join(e.Invalid, "、"))
}
return strings.Join(parts, "")
}
func NewService(repo *Repository, config ConfigReader) *Service {
return &Service{repo: repo, config: config}
}
func (s *Service) Create(ownerID uint64, req CreateRequest) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
rules, err := s.publishRules()
if err != nil {
return nil, err
}
if err := validateRequest(req, rules); err != nil {
return nil, err
}
reviewRequired, err := s.reviewRequired()
if err != nil {
return nil, err
}
return s.repo.Create(ownerID, req, reviewRequired)
}
func (s *Service) ImportExternalUpload(req ExternalUploadRequest, meta ExternalUploadMeta) (*ExternalUploadResponse, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
uploaderName := req.normalizedUploaderName()
if uploaderName == "" {
return nil, ErrMissingUploaderName
}
items, err := parseExternalUploadItems(req.Data)
if err != nil {
return nil, err
}
if len(items) > maxExternalUploadItems {
return nil, ErrTooManyUploadItems
}
rules, err := s.publishRules()
if err != nil {
return nil, err
}
clientUploadTime := parseClientUploadTime(req.UploadTime)
results := make([]ExternalUploadResult, 0, len(items))
resp := &ExternalUploadResponse{Total: len(items)}
for index, item := range items {
createReq := externalAccountToCreateRequest(uploaderName, req.UploadTime, item)
if err := validateRequest(createReq, rules); err != nil {
if len(items) == 1 {
return nil, err
}
resp.Failed++
results = append(results, ExternalUploadResult{Index: index, Error: err.Error()})
continue
}
parsedPayload, _ := json.Marshal(item)
dto, err := s.repo.CreateFromExternalUpload(externalUploadCreate{
UploaderName: uploaderName,
ClientUploadTime: clientUploadTime,
ClientIP: meta.IP,
RawPayload: meta.RawPayload,
ParsedPayload: parsedPayload,
}, createReq)
if err != nil {
if len(items) == 1 {
return nil, err
}
resp.Failed++
results = append(results, ExternalUploadResult{Index: index, Error: err.Error()})
continue
}
resp.Success++
result := ExternalUploadResult{
Index: index,
ListingID: dto.ID,
AccountID: dto.AccountID,
Status: dto.Status,
ReviewStatus: dto.ReviewStatus,
}
results = append(results, result)
if len(items) == 1 {
resp.ListingID = dto.ID
resp.AccountID = dto.AccountID
resp.Status = dto.Status
resp.ReviewStatus = dto.ReviewStatus
}
}
resp.Items = results
if resp.Success == 0 && resp.Failed > 0 {
return resp, ErrInvalidInput
}
return resp, nil
}
func (s *Service) Update(ownerID uint64, id uint64, req UpdateRequest) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
rules, err := s.publishRules()
if err != nil {
return nil, err
}
if err := validateRequest(req, rules); err != nil {
return nil, err
}
reviewRequired, err := s.reviewRequired()
if err != nil {
return nil, err
}
return s.repo.Update(ownerID, id, req, reviewRequired)
}
func (s *Service) SubmitReview(ownerID uint64, id uint64) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
reviewRequired, err := s.reviewRequired()
if err != nil {
return nil, err
}
return s.repo.SubmitReview(ownerID, id, reviewRequired)
}
func (s *Service) ListPendingReview() ([]ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListPendingReview()
}
func (s *Service) ListAdmin(query AdminListQuery) (*AdminListResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListAdmin(query)
}
func (s *Service) FindAdmin(id uint64) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.FindAdmin(id)
}
func (s *Service) AdminOffline(adminID uint64, id uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if req.Reason == "" {
return nil, ErrInvalidInput
}
return s.repo.AdminOffline(adminID, id, req, meta)
}
func (s *Service) AdminMarkAbnormal(adminID uint64, id uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if req.Reason == "" {
return nil, ErrInvalidInput
}
return s.repo.AdminMarkAbnormal(adminID, id, req, meta)
}
func (s *Service) Approve(id uint64) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.Approve(id)
}
func (s *Service) Reject(id uint64, req ReviewRequest) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if req.Reason == "" {
return nil, ErrInvalidInput
}
return s.repo.Reject(id, req)
}
func (s *Service) Offline(ownerID uint64, id uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.Offline(ownerID, id)
}
func (s *Service) ListPublic(query PublicListQuery) (*PublicListResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListPublic(query)
}
func (s *Service) ListMine(ownerID uint64) ([]ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListMine(ownerID)
}
func (s *Service) FindPublic(id uint64) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.FindPublic(id)
}
func (s *Service) FindPublicCoverKey(id uint64) (string, error) {
if s.repo == nil {
return "", ErrDependencyUnavailable
}
return s.repo.FindPublicCoverKey(id)
}
func (s *Service) FindPublicScreenshotKey(id uint64, index int) (string, error) {
if s.repo == nil {
return "", ErrDependencyUnavailable
}
return s.repo.FindPublicScreenshotKey(id, index)
}
func (s *Service) FindMine(ownerID uint64, id uint64) (*ListingDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.FindMine(ownerID, id)
}
type publishRules struct {
FireLevelMin int
}
func validateRequest(req CreateRequest, rules publishRules) error {
if strings.TrimSpace(req.Title) == "" {
return ErrMissingTitle
}
if strings.TrimSpace(req.ServerRegion) == "" {
return ErrMissingServerRegion
}
if normalizedListingPrice(req) <= 0 {
return ErrInvalidPrice
}
if req.DepositAmount < 0 {
return ErrInvalidDeposit
}
if consumables := consumableValue(req.AssetSummary); consumables > 0 && req.DepositAmount <= 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},
Price: price,
DepositAmount: item.Deposit,
}
}
func externalUploadTitle(item ExternalAccountData, insurance string, hafCoinM float64) string {
parts := []string{
strings.TrimSpace(item.Rank),
insurance,
fmt.Sprintf("%.1fM", hafCoinM),
strings.TrimSpace(item.LoginMethod),
}
title := strings.TrimSpace(strings.Join(cleanStrings(parts), " "))
if title == "" {
return "开放接口上传账号"
}
if len([]rune(title)) > 128 {
return string([]rune(title)[:128])
}
return title
}
func serverRegionFromLoginMethod(value string) string {
value = strings.TrimSpace(value)
switch {
case strings.Contains(value, "微信"):
return "微信"
case strings.Contains(strings.ToLower(value), "steam"):
return "Steam"
default:
return "QQ"
}
}
func insuranceFromSafeSlots(value int) string {
switch value {
case 9:
return "3*3"
case 6:
return "2*3"
case 4:
return "2*2"
case 2:
return "2*1"
default:
if value > 0 {
return strconv.Itoa(value) + "格"
}
return ""
}
}
func levelText(value int) string {
if value <= 0 {
return ""
}
return strconv.Itoa(value) + "级"
}
func externalResources(inventory ExternalUploadInventory) []any {
resources := []any{
map[string]any{"key": "awmAmmo", "label": "AWM子弹", "price": "0.6元/发", "quantity": inventory.AWMBullets, "mode": "收费"},
map[string]any{"key": "helmet6", "label": "6头", "price": "1.5元/个", "quantity": inventory.Level6Helmets, "mode": "收费"},
map[string]any{"key": "armor6", "label": "6甲", "price": "2.5元/个", "quantity": inventory.Level6Armor, "mode": "收费"},
}
return resources
}
func externalSkinGroups(skins []string) map[string][]string {
groups := map[string][]string{
"melee": {},
"imported": {},
}
for _, skin := range skins {
switch skin {
case "坠星者", "暗星", "龙牙", "信条", "怜悯", "赤枭", "影锋", "黑海", "北极星", "电锯惊魂", "处刑者":
groups["melee"] = append(groups["melee"], skin)
default:
groups["imported"] = append(groups["imported"], skin)
}
}
return groups
}
func normalizeBanRecord(value string) string {
value = strings.TrimSpace(value)
switch value {
case "", "无", "无封禁", "无封禁记录":
return "无封禁记录"
default:
return value
}
}
func commonRegions(value string) []string {
return cleanStrings(strings.Split(value, ","))
}
func cleanStrings(values []string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func consumableValue(summary map[string]any) float64 {
if summary == nil {
return 0
}
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() (bool, error) {
if s.config == nil {
return false, nil
}
value, err := s.config.FindValue(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() (publishRules, error) {
rules := publishRules{FireLevelMin: defaultFireLevelMin}
if s.config == nil {
return rules, nil
}
value, err := s.config.FindValue(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
}