Files
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

302 lines
9.2 KiB
Go

package listing
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"time"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
sourceChannelWebsite = "站内发布"
sourceChannelExternalUnknown = "未填写"
)
func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*AdminListResult, error) {
page := query.Page
if page <= 0 {
page = 1
}
pageSize := query.PageSize
if pageSize <= 0 {
pageSize = query.Limit
}
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
if hasAdminAssetFilters(query) {
var rows []listingRow
err := r.applyAdminListFilters(r.baseQuery(ctx), query).
Order("l.id DESC").
Scan(&rows).Error
if err != nil {
return nil, err
}
items := filterAdminListings(rowsToDTO(rows), query)
total := int64(len(items))
start := (page - 1) * pageSize
if start < 0 {
start = 0
}
if start >= len(items) {
items = []ListingDTO{}
} else {
end := start + pageSize
if end > len(items) {
end = len(items)
}
items = items[start:end]
}
return &AdminListResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
db := r.applyAdminListFilters(r.baseQuery(ctx), query)
offset := (page - 1) * pageSize
if offset < 0 {
offset = 0
}
var rows []listingRow
err := db.Order("l.id DESC").Limit(pageSize).Offset(offset).Scan(&rows).Error
if err != nil {
return nil, err
}
var total int64
// 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,彻底免除第二次 COUNT(*) 扫描
if page == 1 && len(rows) < pageSize {
total = int64(len(rows))
} else {
countDB := r.applyAdminListFilters(r.db.WithContext(ctx).Table("rental_listings AS l"), query)
if err := countDB.Count(&total).Error; err != nil {
return nil, err
}
}
return &AdminListResult{
Items: rowsToDTO(rows),
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *gorm.DB {
if query.OwnerID > 0 {
db = db.Where("l.owner_id = ?", query.OwnerID)
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
upper := strings.ToUpper(keyword)
if strings.HasPrefix(upper, "RO") || strings.HasPrefix(upper, "ORD") {
// 订单号:命中 rental_orders(order_no) 唯一索引
db = db.Where("EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no = ?)", keyword)
} else if strings.HasPrefix(upper, "L") || (len(keyword) >= 12 && isAllDigits(keyword)) {
// 商品编号(如 L前缀或时间计算生成的 12位纯数字如 202609040184):命中 rental_listings(listing_no) 唯一索引
db = db.Where("l.listing_no = ?", keyword)
} else if id, err := strconv.ParseUint(keyword, 10, 64); err == nil && id > 0 && keyword[0] != '0' && id < 100000000 {
db = db.Where(
`(l.listing_no = ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no = ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`,
keyword,
keyword,
id,
id,
id,
)
} else {
like := "%" + keyword + "%"
db = db.Where(
`(l.listing_no LIKE ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no LIKE ?))`,
like,
like,
)
}
}
if query.Status != "" {
db = db.Where("l.status = ?", query.Status)
}
if query.ReviewStatus != "" {
db = db.Where("l.review_status = ?", query.ReviewStatus)
}
if query.MinCoin != nil {
db = db.Where(
"EXISTS (SELECT 1 FROM game_accounts AS coin_account WHERE coin_account.id = l.account_id AND coin_account.haf_coin_amount >= ?)",
coinMToAdminAmount(*query.MinCoin),
)
}
if query.MaxCoin != nil {
db = db.Where(
"EXISTS (SELECT 1 FROM game_accounts AS coin_account WHERE coin_account.id = l.account_id AND coin_account.haf_coin_amount <= ?)",
coinMToAdminAmount(*query.MaxCoin),
)
}
if query.MinDeposit != nil {
db = db.Where("l.deposit_amount_cent >= ?", yuanToAdminCent(*query.MinDeposit))
}
if query.MaxDeposit != nil {
db = db.Where("l.deposit_amount_cent <= ?", yuanToAdminCent(*query.MaxDeposit))
}
return db
}
func coinMToAdminAmount(value float64) int64 {
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
return int64(math.Round(value * 1000000))
}
func yuanToAdminCent(value float64) int64 {
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
return int64(math.Round(value * 100))
}
func (r *Repository) FindAdmin(ctx context.Context, listingID uint64) (*ListingDTO, error) {
return r.findDTO(ctx, "l.id = ?", listingID)
}
func (r *Repository) ListMine(ctx context.Context, ownerID uint64, siteCode ...string) ([]ListingDTO, error) {
var rows []listingRow
base := r.baseQuery(ctx).Where("l.owner_id = ?", ownerID)
if len(siteCode) > 0 {
code := strings.TrimSpace(siteCode[0])
if code != "" {
base = base.Joins("INNER JOIN distribution_sites AS my_ds ON my_ds.id = l.distribution_site_id AND my_ds.site_code = ?", code)
} else {
base = base.Where("l.distribution_site_id IS NULL OR l.distribution_site_id = 0")
}
}
err := base.Order("l.id DESC").Scan(&rows).Error
if err != nil {
return nil, err
}
return sellerListings(rowsToDTO(rows)), nil
}
func (r *Repository) FindMine(ctx context.Context, ownerID uint64, id uint64) (*ListingDTO, error) {
dto, err := r.findDTO(ctx, "l.id = ? AND l.owner_id = ?", id, ownerID)
if err != nil {
return nil, err
}
applySellerListingPrice(dto)
return dto, nil
}
func (r *Repository) findOwnedForUpdate(tx *gorm.DB, ownerID uint64, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
var listing model.RentalListing
if err := tx.Where("id = ? AND owner_id = ?", listingID, ownerID).First(&listing).Error; err != nil {
return nil, nil, err
}
var account model.GameAccount
if err := tx.Where("id = ? AND owner_id = ?", listing.AccountID, ownerID).First(&account).Error; err != nil {
return nil, nil, err
}
return &listing, &account, nil
}
func (r *Repository) findDTO(ctx context.Context, where string, args ...any) (*ListingDTO, error) {
var row listingRow
err := r.baseQuery(ctx).
Where(where, args...).
First(&row).Error
if err != nil {
return nil, err
}
dto := row.toDTO()
return &dto, nil
}
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).Table("rental_listings AS l").
Select(`l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level,
a.haf_coin_amount, a.asset_summary, a.screenshot_urls, COALESCE(u.phone, '') AS owner_phone, COALESCE(u.nickname, '') AS owner_nickname,
CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
COALESCE(src_site.site_code, '') AS distribution_site_code,
COALESCE(src_site.site_name, '') AS distribution_site_name,
CASE
WHEN l.distribution_site_id IS NOT NULL AND src_site.id IS NOT NULL THEN CONCAT('分销站 - ', src_site.site_name, ' (', src_site.site_code, ')')
WHEN lu.id IS NULL THEN ?
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
ELSE lu.source_channel
END AS display_source_channel`, sourceChannelWebsite, sourceChannelExternalUnknown).
Joins("JOIN game_accounts AS a ON a.id = l.account_id").
Joins("LEFT JOIN users AS u ON u.id = l.owner_id").
Joins("LEFT JOIN distribution_sites AS src_site ON src_site.id = l.distribution_site_id").
// 逐商品按 listing_id 索引定位最后一次导入,避免每次列表查询都对全量导入记录分组。
Joins(`LEFT JOIN listing_uploads AS lu ON lu.id = (
SELECT MAX(lu_latest.id) FROM listing_uploads AS lu_latest WHERE lu_latest.listing_id = l.id
)`)
}
func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) {
var listing model.RentalListing
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, listingID).Error; err != nil {
return nil, nil, err
}
var account model.GameAccount
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, listing.AccountID).Error; err != nil {
return nil, nil, err
}
return &listing, &account, nil
}
func (r *Repository) nextListingNo(tx *gorm.DB, now time.Time) (string, error) {
bizDate := now.Format("20060102")
if tx.Dialector.Name() == "mysql" {
if err := tx.Exec(`
INSERT INTO listing_no_sequences (biz_date, next_seq)
VALUES (?, LAST_INSERT_ID(1))
ON DUPLICATE KEY UPDATE next_seq = LAST_INSERT_ID(next_seq + 1)
`, bizDate).Error; err != nil {
return "", err
}
var seq int
if err := tx.Raw("SELECT LAST_INSERT_ID()").Scan(&seq).Error; err != nil {
return "", err
}
return fmt.Sprintf("%s%04d", bizDate, seq), nil
}
var maxNo string
if err := tx.Table("rental_listings").
Select("COALESCE(MAX(listing_no), '')").
Where("listing_no LIKE ?", bizDate+"%").
Scan(&maxNo).Error; err != nil {
return "", err
}
seq := 1
if len(maxNo) > len(bizDate) {
if parsed, err := strconv.Atoi(maxNo[len(bizDate):]); err == nil {
seq = parsed + 1
}
}
return fmt.Sprintf("%s%04d", bizDate, seq), nil
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}