Files
hfb_sys/backend/internal/modules/listing/review.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

437 lines
14 KiB
Go

package listing
import (
"context"
"math"
"strings"
"time"
"hfb_sys/backend/internal/listingstatus"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/notification"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (r *Repository) ListPendingReview(ctx context.Context) ([]ListingDTO, error) {
var rows []listingRow
err := r.baseQuery(ctx).
Where("l.review_status = ? AND l.status <> ?", "pending", "offline").
Order("l.updated_at ASC, l.id ASC").
Limit(200).
Scan(&rows).Error
if err != nil {
return nil, err
}
return rowsToDTO(rows), nil
}
func (r *Repository) AdminOffline(ctx context.Context, adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) {
return r.adminUpdateStatus(ctx, adminID, listingID, req, meta, "offline", "offline", "listing.admin_offline", "商品已被后台下架", "你的租号商品已被后台下架,请查看原因后处理。")
}
func (r *Repository) AdminMarkAbnormal(ctx context.Context, adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta) (*ListingDTO, error) {
return r.adminUpdateStatus(ctx, adminID, listingID, req, meta, "abnormal", "abnormal", "listing.mark_abnormal", "商品已被标记异常", "你的租号商品已被后台标记异常,请联系客服处理。")
}
func (r *Repository) TransferOwner(ctx context.Context, adminID uint64, listingID uint64, req TransferOwnerRequest, meta AuditMeta) (*ListingDTO, error) {
var dto *ListingDTO
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
listing, account, err := r.findForReviewUpdate(tx, listingID)
if err != nil {
return err
}
if listing.Status == "rented" || listing.InTransaction {
return ErrListingLocked
}
if listing.OwnerID == req.TargetUserID {
dto = toDTO(*account, *listing)
return nil
}
var target model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND status = ? AND realname_status = ?", req.TargetUserID, "active", "verified").
First(&target).Error; err != nil {
if IsNotFound(err) {
return ErrTargetOwnerInvalid
}
return err
}
beforeOwnerID := listing.OwnerID
listing.OwnerID = target.ID
listing.HandoffMode = "owner"
listing.SettlementMode = "owner_wallet"
listing.ManagedAdminID = nil
account.OwnerID = target.ID
if err := tx.Save(account).Error; err != nil {
return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
if err := tx.Model(&model.ListingUpload{}).
Where("listing_id = ?", listing.ID).
Update("owner_id", target.ID).Error; err != nil {
return err
}
if err := transferListingGroupOwner(tx, listing.ID, beforeOwnerID, target.ID); err != nil {
return err
}
if r.chatCreator != nil {
if _, err := r.chatCreator.EnsureListingConversation(tx, *listing, 0); err != nil {
return err
}
}
if err := appendAuditLog(tx, adminID, "listing.transfer_owner", "listing", listing.ID, meta, map[string]any{
"listing_id": listing.ID,
"account_id": account.ID,
"before_owner_id": beforeOwnerID,
"after_owner_id": target.ID,
"reason": req.Reason,
}); err != nil {
return err
}
dto = toDTO(*account, *listing)
dto.OwnerPhone = target.Phone
dto.OwnerNickname = target.Nickname
return nil
})
return dto, err
}
func transferListingGroupOwner(tx *gorm.DB, listingID uint64, beforeOwnerID uint64, afterOwnerID uint64) error {
var conversation model.ChatConversation
err := tx.Where("listing_id = ? AND type = ?", listingID, "listing_group").First(&conversation).Error
if err != nil {
if IsNotFound(err) {
return nil
}
return err
}
if err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ? AND role = ?",
conversation.ID, "user", beforeOwnerID, "owner").
Delete(&model.ChatParticipant{}).Error; err != nil {
return err
}
participant := model.ChatParticipant{
ConversationID: conversation.ID,
ParticipantType: "user",
ParticipantID: afterOwnerID,
Role: "owner",
JoinedAt: time.Now(),
}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil {
return err
}
message := model.ChatMessage{
ConversationID: conversation.ID,
SenderType: "system",
SenderRole: "system",
ContentType: "system",
Content: "账号归属已由后台转移给新号主",
AttachmentURLS: emptyJSONListForListing(),
}
if err := tx.Create(&message).Error; err != nil {
return err
}
return tx.Model(&model.ChatConversation{}).
Where("id = ?", conversation.ID).
Updates(map[string]interface{}{
"last_message_id": message.ID,
"last_message_preview": message.Content,
"last_message_at": message.CreatedAt,
}).Error
}
func emptyJSONListForListing() datatypes.JSON {
return datatypes.JSON([]byte("[]"))
}
func (r *Repository) adminUpdateStatus(ctx context.Context, adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta, listingStatus string, accountStatus string, action string, title string, content string) (*ListingDTO, error) {
var dto *ListingDTO
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
listing, account, err := r.findForReviewUpdate(tx, listingID)
if err != nil {
return err
}
if listing.Status == "rented" || listing.InTransaction {
return ErrListingLocked
}
beforeListingStatus := listing.Status
beforeAccountStatus := account.Status
beforeReviewReason := listing.ReviewReason
listing.Status = listingStatus
listing.ReviewReason = req.Reason
if listingStatus != "published" {
listing.PublishedAt = nil
}
account.Status = accountStatus
if err := tx.Save(account).Error; err != nil {
return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
source := listingstatus.SourceAdmin
if listingStatus == "published" {
source = listingstatus.SourceReview
}
if err := listingstatus.AppendTransition(tx, listing, beforeListingStatus, source, listingstatus.ActorAdmin, adminID, req.Reason); err != nil {
return err
}
if err := notification.Append(tx, notification.Entry{
UserID: listing.OwnerID,
Type: "listing_admin",
Title: title,
Content: content,
BizType: "listing",
BizID: &listingID,
}); err != nil {
return err
}
if err := appendAuditLog(tx, adminID, action, "listing", listing.ID, meta, map[string]any{
"listing_id": listing.ID,
"account_id": account.ID,
"owner_id": listing.OwnerID,
"reason": req.Reason,
"before_listing_status": beforeListingStatus,
"after_listing_status": listing.Status,
"before_account_status": beforeAccountStatus,
"after_account_status": account.Status,
"before_review_reason": beforeReviewReason,
"after_review_reason": listing.ReviewReason,
}); err != nil {
return err
}
dto = toDTO(*account, *listing)
return nil
})
return dto, err
}
func (r *Repository) Approve(ctx context.Context, listingID uint64) (*ListingDTO, error) {
var dto *ListingDTO
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
listing, account, err := r.findForReviewUpdate(tx, listingID)
if err != nil {
return err
}
if listing.Status == "rented" || listing.InTransaction {
return ErrListingLocked
}
fromStatus := listing.Status
now := time.Now()
listing.Status = "published"
listing.ReviewStatus = "approved"
listing.ReviewReason = ""
listing.PublishedAt = &now
account.Status = "published"
if err := tx.Save(account).Error; err != nil {
return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
if err := listingstatus.AppendTransition(tx, listing, fromStatus, listingstatus.SourceReview, listingstatus.ActorAdmin, 0, "审核通过上架"); err != nil {
return err
}
listingID := listing.ID
if err := notification.Append(tx, notification.Entry{
UserID: listing.OwnerID,
Type: "listing_review",
Title: "发布审核通过",
Content: "你的租号发布已审核通过并上架。",
BizType: "listing",
BizID: &listingID,
}); err != nil {
return err
}
dto = toDTO(*account, *listing)
return nil
})
return dto, err
}
func (r *Repository) AdjustReviewPrice(ctx context.Context, adminID uint64, listingID uint64, req AdminPriceAdjustRequest, meta AuditMeta) (*ListingDTO, error) {
var dto *ListingDTO
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
listing, account, err := r.findForReviewUpdate(tx, listingID)
if err != nil {
return err
}
if listing.Status == "rented" || listing.InTransaction {
return ErrListingLocked
}
summary := decodeAssetSummary(account.AssetSummary)
if summary == nil {
summary = map[string]any{}
}
breakdown := ensurePriceBreakdown(summary)
coinWan := float64(account.HafCoinAmount) / 10000
consumablePrice := readSummaryNumber(breakdown["consumable_price"])
if consumablePrice <= 0 {
consumablePrice = consumableValue(summary)
}
sellerTotalPrice := readSummaryNumber(breakdown["seller_total_price"])
if sellerTotalPrice <= 0 {
sellerTotalPrice = math.Max(0, centToYuan(listing.PriceCent)-consumablePrice)
}
sellerCoinBasePrice := readSummaryNumber(breakdown["seller_coin_base_price"])
if sellerCoinBasePrice <= 0 {
sellerCoinBasePrice = math.Max(0, sellerTotalPrice-consumablePrice)
}
sellerRatio := readSummaryNumber(breakdown["seller_ratio"])
if sellerRatio <= 0 && sellerCoinBasePrice > 0 {
sellerRatio = roundRatio(coinWan / sellerCoinBasePrice)
}
buyerCoinBasePrice, buyerTotalPrice, buyerRatio := calculateAdminAdjustedPrice(req, coinWan, consumablePrice)
if buyerCoinBasePrice <= 0 || buyerTotalPrice <= 0 || buyerRatio <= 0 {
return ErrInvalidPrice
}
beforePriceCent := listing.PriceCent
beforeRatio := readSummaryNumber(breakdown["buyer_ratio"])
if beforeRatio <= 0 && centToYuan(listing.PriceCent) > consumablePrice {
beforeRatio = roundRatio(coinWan / (centToYuan(listing.PriceCent) - consumablePrice))
}
listing.PriceCent = yuanToCent(buyerTotalPrice)
summary["publish_ratio"] = buyerRatio
breakdown["seller_coin_base_price"] = roundMoney(sellerCoinBasePrice)
breakdown["seller_total_price"] = roundMoney(sellerTotalPrice)
breakdown["seller_ratio"] = sellerRatio
breakdown["buyer_coin_base_price"] = buyerCoinBasePrice
breakdown["buyer_total_price"] = buyerTotalPrice
breakdown["buyer_ratio"] = buyerRatio
breakdown["platform_markup_amount"] = roundMoney(buyerTotalPrice - sellerTotalPrice)
breakdown["platform_rule_type"] = "admin_adjusted"
breakdown["admin_adjust_reason"] = strings.TrimSpace(req.Reason)
breakdown["admin_adjusted_at"] = time.Now().Format(time.RFC3339)
breakdown["admin_adjusted_by"] = adminID
summary["price_breakdown"] = breakdown
assetSummary, err := marshalAssetSummary(summary)
if err != nil {
return err
}
account.AssetSummary = assetSummary
if err := tx.Save(account).Error; err != nil {
return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "listing.adjust_review_price", "listing", listing.ID, meta, map[string]any{
"listing_id": listing.ID,
"account_id": account.ID,
"owner_id": listing.OwnerID,
"before_price_cent": beforePriceCent,
"after_price_cent": listing.PriceCent,
"before_buyer_ratio": beforeRatio,
"after_buyer_ratio": buyerRatio,
"platform_markup": breakdown["platform_markup_amount"],
"adjust_reason": req.Reason,
"buyer_coin_base": buyerCoinBasePrice,
"consumable_price": consumablePrice,
"seller_total_price": sellerTotalPrice,
}); err != nil {
return err
}
dto = toDTO(*account, *listing)
return nil
})
return dto, err
}
func (r *Repository) Reject(ctx context.Context, listingID uint64, req ReviewRequest) (*ListingDTO, error) {
var dto *ListingDTO
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
listing, account, err := r.findForReviewUpdate(tx, listingID)
if err != nil {
return err
}
if listing.Status == "rented" || listing.InTransaction {
return ErrListingLocked
}
listing.Status = "draft"
listing.ReviewStatus = "rejected"
listing.ReviewReason = req.Reason
listing.PublishedAt = nil
account.Status = "draft"
if err := tx.Save(account).Error; err != nil {
return err
}
if err := tx.Save(listing).Error; err != nil {
return err
}
listingID := listing.ID
if err := notification.Append(tx, notification.Entry{
UserID: listing.OwnerID,
Type: "listing_review",
Title: "发布审核未通过",
Content: "你的租号发布未通过审核。原因:" + req.Reason + "。请根据原因修改后重新提交。",
BizType: "listing",
BizID: &listingID,
}); err != nil {
return err
}
dto = toDTO(*account, *listing)
return nil
})
return dto, err
}
func (r *Repository) UpdateScreenshots(ctx context.Context, adminID uint64, listingID uint64, req AdminUpdateScreenshotsRequest, meta AuditMeta) (*ListingDTO, error) {
var dto *ListingDTO
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
listing, account, err := r.findForReviewUpdate(tx, listingID)
if err != nil {
return err
}
if listing.Status == "rented" || listing.InTransaction {
return ErrListingLocked
}
screenshots, err := marshalScreenshots(req.ScreenshotURLs)
if err != nil {
return err
}
account.ScreenshotURLS = screenshots
if req.ScreenshotGroups != nil {
summary := decodeAssetSummary(account.AssetSummary)
if summary == nil {
summary = map[string]any{}
}
summary["screenshot_groups"] = req.ScreenshotGroups
assetSummary, err := marshalAssetSummary(summary)
if err != nil {
return err
}
account.AssetSummary = assetSummary
}
if err := tx.Save(account).Error; err != nil {
return err
}
if err := appendAuditLog(tx, adminID, "listing.update_screenshots", "listing", listing.ID, meta, map[string]any{
"listing_id": listing.ID,
"account_id": account.ID,
"screenshot_urls": req.ScreenshotURLs,
}); err != nil {
return err
}
dto = toDTO(*account, *listing)
return nil
})
return dto, err
}