Files
hfb_sys/backend/internal/modules/listing/presenter.go
T
yml2213 cbd5412315 test: 后端测试从 SQLite 全面切换到 MySQL,与生产环境对齐
测试库不再使用 SQLite 内存库,改为每个测试包独立的 MySQL 库,
表结构由 backend/migrations 的真实迁移建立。

基础设施
- 新增 internal/database/testdb.go:NewTestDB() 保持原签名(28 处调用零改动),
  内部连接 MySQL 测试库,首次调用重建库并执行全部真实迁移,之后每次调用清空全表
- 库名 = 测试库基础名 + 包目录名,同包共享、跨包隔离,使 go test ./... 可并发执行
  (共用一库时各包会互相 DROP/CREATE 导致 database exists)
- 新增 NewDryRunDB():MySQL 方言 + DryRun,供断言 SQL 形态的 5 处测试使用
- 新增 AutoMigrateIfMissing():只对不存在的表 AutoMigrate,
  避免改动迁移定义的索引与约束
- 新增 scripts/prepare-test-db.sh:建库与通配授权,可重复执行
- 33 处 sqlite.Open 全部替换,26 个测试文件改用 MySQL
- 删除 46 处手写 SQLite 方言 DDL(AUTOINCREMENT / INTEGER PRIMARY KEY / TEXT),
  表统一由真实迁移建立
- go.mod 移除 gorm.io/driver/sqlite 与 mattn/go-sqlite3
- 删除生产代码里 distribution/repository.go 的 sqlite 方言分支(strftime)

对齐环境后暴露并修复的真实问题
- DATE(col) 在 MySQL 返回 DATE 类型,使 Date string 字段变成 RFC3339 时间串,
  大盘日期会带出时间部分;28 处改为 DATE_FORMAT(col, '%Y-%m-%d')
- 系统自动创建分销结算批次时 created_by 写入 0,违反指向 admin_users 的外键,
  生产会直接插入失败;改为写 NULL(新增 adminRef helper)
- MySQL DATETIME 仅到秒,静默截断 Go 侧写入的时间,
  导致租用时长计算与结算时点回读比对偏差;
  rental_orders 的 9 个业务时间列提到 DATETIME(6)
- 测试数据补齐严格模式要求的 NOT NULL 列、修正超出列长的取值、补齐外键父记录

迁移
- 000065 的 ALTER TABLE rental_orders 一并完成业务时间列精度调整,
  feature 侧不再单开迁移文件

脚本与文档
- check.sh / check-backend-coverage.sh 去掉 -p 1 串行与 SQLite 相关注释
- 更新 docs/Repository层测试补充总结.md 的测试运行说明
2026-09-10 14:34:01 +08:00

469 lines
13 KiB
Go

package listing
import (
"encoding/json"
"math"
"net/url"
"strconv"
"strings"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/pkg/money"
"gorm.io/datatypes"
)
type listingRow struct {
model.RentalListing
Title string
OwnerPhone string
OwnerNickname string
DisplaySourceChannel string `gorm:"column:display_source_channel"`
IsExternalUpload bool `gorm:"column:is_external_upload"`
DistributionSiteCode string `gorm:"column:distribution_site_code"`
DistributionSiteName string `gorm:"column:distribution_site_name"`
Description string
GameName string
ServerRegion string
LoginPlatform string
RankLevel string
HafCoinAmount int64
AssetSummary datatypes.JSON `gorm:"column:asset_summary"`
ScreenshotURLS datatypes.JSON `gorm:"column:screenshot_urls"`
}
func rowsToDTO(rows []listingRow) []ListingDTO {
items := make([]ListingDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO())
}
return items
}
func normalizedListingPriceCent(req CreateRequest) int64 {
if req.AssetSummary != nil {
if breakdown, ok := req.AssetSummary["price_breakdown"].(map[string]any); ok {
buyerPrice := readSummaryNumber(breakdown["buyer_total_price"])
if buyerPrice > 0 {
return yuanToCent(buyerPrice)
}
}
}
return req.PriceCent
}
func publicListings(items []ListingDTO) []ListingDTO {
for index := range items {
applyPublicListingURLs(&items[index])
}
return items
}
func sellerListings(items []ListingDTO) []ListingDTO {
for index := range items {
applySellerListingPrice(&items[index])
}
return items
}
func applyPublicListingURLs(item *ListingDTO) {
if item == nil {
return
}
item.SourceChannel = ""
item.IsExternalUpload = false
rawScreenshotURLs := item.ScreenshotURLS
item.ScreenshotURLS = publicScreenshotURLs(item.ID, item.ScreenshotURLS, item.Status, item.ReviewStatus)
if item.AssetSummary != nil {
delete(item.AssetSummary, "import_meta")
delete(item.AssetSummary, "price_breakdown")
applyPublicScreenshotGroups(item.ID, item.AssetSummary, rawScreenshotURLs, item.ScreenshotURLS)
}
}
func applyPublicScreenshotGroups(listingID uint64, assetSummary map[string]any, rawURLs []string, publicURLs []string) {
rawGroups, ok := assetSummary["screenshot_groups"].(map[string]any)
if !ok {
return
}
urlMap := make(map[string]string, len(rawURLs))
keyMap := make(map[string]string, len(rawURLs))
for i, rawURL := range rawURLs {
if i < len(publicURLs) {
urlMap[rawURL] = publicURLs[i]
if key := extractListingObjectKey(rawURL); key != "" {
keyMap[key] = publicURLs[i]
}
}
}
newGroups := make(map[string]any, len(rawGroups))
for groupKey, groupVal := range rawGroups {
switch list := groupVal.(type) {
case []string:
converted := make([]string, len(list))
for i, u := range list {
if pub, found := urlMap[u]; found {
converted[i] = pub
} else if key := extractListingObjectKey(u); key != "" && keyMap[key] != "" {
converted[i] = keyMap[key]
} else {
converted[i] = u
}
}
newGroups[groupKey] = converted
case []any:
converted := make([]string, len(list))
for i, v := range list {
if s, ok := v.(string); ok {
if pub, found := urlMap[s]; found {
converted[i] = pub
} else if key := extractListingObjectKey(s); key != "" && keyMap[key] != "" {
converted[i] = keyMap[key]
} else {
converted[i] = s
}
}
}
newGroups[groupKey] = converted
default:
newGroups[groupKey] = groupVal
}
}
assetSummary["screenshot_groups"] = newGroups
}
func applySellerListingPrice(item *ListingDTO) {
if item == nil {
return
}
item.SourceChannel = ""
item.IsExternalUpload = false
if item.AssetSummary == nil {
return
}
breakdown, ok := item.AssetSummary["price_breakdown"].(map[string]any)
if !ok {
return
}
sellerPrice := readSummaryNumber(breakdown["seller_total_price"])
if sellerPrice > 0 {
item.PriceCent = int64(math.Round(sellerPrice * 100))
}
sellerRatio := readSummaryNumber(breakdown["seller_ratio"])
if sellerRatio > 0 {
item.AssetSummary["publish_ratio"] = sellerRatio
}
delete(breakdown, "buyer_coin_base_price")
delete(breakdown, "buyer_total_price")
delete(breakdown, "buyer_ratio")
delete(breakdown, "platform_markup_amount")
delete(breakdown, "platform_rule_type")
}
func (row listingRow) toDTO() ListingDTO {
assetSummary := decodeAssetSummary(row.AssetSummary)
screenshotURLS := cleanScreenshotURLs(decodeScreenshots(row.ScreenshotURLS))
reviewStatus, reviewReason := normalizedReviewState(row.Status, row.ReviewStatus, row.ReviewReason)
return ListingDTO{
ID: row.ID,
ListingNo: row.ListingNo,
AccountID: row.AccountID,
OwnerID: row.OwnerID,
OwnerPhone: row.OwnerPhone,
OwnerNickname: row.OwnerNickname,
SourceChannel: row.DisplaySourceChannel,
IsExternalUpload: row.IsExternalUpload,
Title: row.Title,
Description: row.Description,
GameName: row.GameName,
ServerRegion: row.ServerRegion,
LoginPlatform: row.LoginPlatform,
RankLevel: row.RankLevel,
HafCoinAmount: row.HafCoinAmount,
AssetSummary: assetSummary,
ScreenshotURLS: screenshotURLS,
CoverURL: publicCoverURL(row.ID, screenshotURLS, row.Status, row.ReviewStatus),
PriceCent: row.PriceCent,
DepositAmountCent: row.DepositAmountCent,
IsAccelerated: isAcceleratedSale(assetSummary),
InTransaction: row.InTransaction,
Status: row.Status,
ReviewStatus: reviewStatus,
HandoffMode: row.HandoffMode,
SettlementMode: row.SettlementMode,
ManagedAdminID: row.ManagedAdminID,
DistributionSiteID: row.DistributionSiteID,
DistributionSiteCode: row.DistributionSiteCode,
DistributionSiteName: row.DistributionSiteName,
ReviewReason: reviewReason,
PublishedAt: row.PublishedAt,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO {
assetSummary := decodeAssetSummary(account.AssetSummary)
screenshotURLS := cleanScreenshotURLs(decodeScreenshots(account.ScreenshotURLS))
reviewStatus, reviewReason := normalizedReviewState(listing.Status, listing.ReviewStatus, listing.ReviewReason)
return &ListingDTO{
ID: listing.ID,
ListingNo: listing.ListingNo,
AccountID: account.ID,
OwnerID: listing.OwnerID,
Title: account.Title,
Description: account.Description,
GameName: account.GameName,
ServerRegion: account.ServerRegion,
LoginPlatform: account.LoginPlatform,
RankLevel: account.RankLevel,
HafCoinAmount: account.HafCoinAmount,
AssetSummary: assetSummary,
ScreenshotURLS: screenshotURLS,
CoverURL: publicCoverURL(listing.ID, screenshotURLS, listing.Status, listing.ReviewStatus),
PriceCent: listing.PriceCent,
DepositAmountCent: listing.DepositAmountCent,
IsAccelerated: isAcceleratedSale(assetSummary),
InTransaction: listing.InTransaction,
Status: listing.Status,
ReviewStatus: reviewStatus,
HandoffMode: listing.HandoffMode,
SettlementMode: listing.SettlementMode,
ManagedAdminID: listing.ManagedAdminID,
DistributionSiteID: listing.DistributionSiteID,
ReviewReason: reviewReason,
PublishedAt: listing.PublishedAt,
CreatedAt: listing.CreatedAt,
UpdatedAt: listing.UpdatedAt,
}
}
func normalizedReviewState(status string, reviewStatus string, reviewReason string) (string, string) {
if status == "offline" {
return "none", reviewReason
}
return reviewStatus, reviewReason
}
func marshalScreenshots(urls []string) (datatypes.JSON, error) {
cleaned := cleanScreenshotURLs(urls)
if len(cleaned) > 12 {
cleaned = cleaned[:12]
}
raw, err := json.Marshal(cleaned)
if err != nil {
return nil, err
}
return datatypes.JSON(raw), nil
}
func marshalAssetSummary(summary map[string]any) (datatypes.JSON, error) {
if summary == nil {
return nil, nil
}
raw, err := json.Marshal(summary)
if err != nil {
return nil, err
}
return datatypes.JSON(raw), nil
}
func decodeScreenshots(raw datatypes.JSON) []string {
if len(raw) == 0 {
return []string{}
}
var urls []string
if err := json.Unmarshal(raw, &urls); err != nil {
return []string{}
}
return urls
}
func decodeAssetSummary(raw datatypes.JSON) map[string]any {
if len(raw) == 0 {
return nil
}
var summary map[string]any
if err := json.Unmarshal(raw, &summary); err != nil {
return nil
}
return summary
}
func isAcceleratedSale(summary map[string]any) bool {
if summary == nil {
return false
}
breakdown, ok := summary["price_breakdown"].(map[string]any)
if !ok {
return false
}
referenceRatio := readSummaryNumber(breakdown["seller_reference_ratio"])
sellerRatio := readSummaryNumber(breakdown["seller_ratio"])
acceleratedRatio := readSummaryNumber(breakdown["accelerated_sale_ratio"])
if referenceRatio <= 0 {
return false
}
return sellerRatio > referenceRatio || acceleratedRatio > referenceRatio
}
func readSummaryNumber(value any) float64 {
switch typed := value.(type) {
case float64:
return typed
case float32:
return float64(typed)
case int:
return float64(typed)
case int64:
return float64(typed)
case json.Number:
number, err := typed.Float64()
if err != nil {
return 0
}
return number
case string:
number, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
if err != nil {
return 0
}
return number
default:
return 0
}
}
func ensurePriceBreakdown(summary map[string]any) map[string]any {
if summary == nil {
return map[string]any{}
}
breakdown, ok := summary["price_breakdown"].(map[string]any)
if ok {
return breakdown
}
breakdown = map[string]any{}
if raw, ok := summary["price_breakdown"].(map[string]interface{}); ok {
for key, value := range raw {
breakdown[key] = value
}
}
return breakdown
}
func calculateAdminAdjustedPrice(req AdminPriceAdjustRequest, coinWan float64, consumablePrice float64) (float64, float64, float64) {
if req.BuyerTotalPriceCent > 0 {
buyerTotalPrice := roundMoney(centToYuan(req.BuyerTotalPriceCent))
buyerCoinBasePrice := roundMoney(buyerTotalPrice - consumablePrice)
if buyerCoinBasePrice <= 0 || coinWan <= 0 {
return 0, 0, 0
}
return buyerCoinBasePrice, buyerTotalPrice, roundRatio(coinWan / buyerCoinBasePrice)
}
if req.BuyerRatio <= 0 || coinWan <= 0 {
return 0, 0, 0
}
buyerCoinBasePrice := roundMoney(coinWan / req.BuyerRatio)
buyerTotalPrice := roundMoney(buyerCoinBasePrice + consumablePrice)
return buyerCoinBasePrice, buyerTotalPrice, roundRatio(coinWan / buyerCoinBasePrice)
}
func roundRatio(value float64) float64 {
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
return math.Round(value*10) / 10
}
func yuanToCent(value float64) int64 {
return int64(math.Round(roundMoney(value) * 100))
}
func centToYuan(value int64) float64 {
return float64(value) / 100
}
func cleanScreenshotURLs(urls []string) []string {
cleaned := make([]string, 0, len(urls))
seen := make(map[string]struct{}, len(urls))
for _, fileURL := range urls {
fileURL = normalizeDefaultScreenshotURL(strings.TrimSpace(fileURL))
if fileURL == "" {
continue
}
if _, ok := seen[fileURL]; ok {
continue
}
seen[fileURL] = struct{}{}
cleaned = append(cleaned, fileURL)
}
return cleaned
}
// normalizeDefaultScreenshotURL 把历史默认图地址升级到当前版本,避免浏览器继续吃旧 SVG 缓存。
func normalizeDefaultScreenshotURL(fileURL string) string {
if fileURL == "" {
return ""
}
switch {
case fileURL == "/api/listings/default-upload-screenshot",
strings.HasPrefix(fileURL, "/api/listings/default-upload-screenshot?"):
return defaultUploadScreenshot
default:
return fileURL
}
}
func firstScreenshotURL(urls []string) string {
if len(urls) == 0 {
return ""
}
return urls[0]
}
func publicCoverURL(listingID uint64, urls []string, status string, reviewStatus string) string {
fallback := firstScreenshotURL(urls)
if status != "published" || reviewStatus != "approved" || extractListingObjectKey(fallback) == "" {
return fallback
}
return "/api/listings/" + strconv.FormatUint(listingID, 10) + "/cover"
}
func publicScreenshotURLs(listingID uint64, urls []string, status string, reviewStatus string) []string {
if status != "published" || reviewStatus != "approved" {
return urls
}
publicURLs := make([]string, 0, len(urls))
for index, fileURL := range urls {
if extractListingObjectKey(fileURL) == "" {
publicURLs = append(publicURLs, fileURL)
continue
}
publicURLs = append(publicURLs, "/api/listings/"+strconv.FormatUint(listingID, 10)+"/screenshots/"+strconv.Itoa(index))
}
return publicURLs
}
func extractListingObjectKey(fileURL string) string {
if fileURL == "" {
return ""
}
parsed, err := url.Parse(fileURL)
if err != nil {
return ""
}
key := parsed.Query().Get("key")
if key == "" {
return ""
}
if !strings.HasPrefix(key, "listing/") || strings.Contains(key, "..") {
return ""
}
return key
}
// roundMoney 使用统一的角精度(0.1元)
func roundMoney(value float64) float64 {
return money.Round(value)
}