合并 main 分支并融合订单列表最小化与 SQL 治理
- Auth/权限中间件取 main 演进版(错误细分、token 来源),保留 dev 的用户 cookie 与登出撤销语义 - 后台订单列表:融合 main 的首页免 COUNT 优化与关键词索引路由,保留 dev 的最小化 AdminOrderListItemDTO(结账明细仍由详情接口返回,同步调整对应集成测试) - 保留 dev 的 token_version 冻结/解冻递增与 main 的状态变化判断及通知 - 前端:保留 dev 登出接口与 main 的 fetchMe silent 参数、SSE 登录跳转;后台订单表按 dev 最小化 DTO 移除号主到手列
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
package listing
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestFilterAdminListingsByAssetLevels(t *testing.T) {
|
||||
items := []ListingDTO{
|
||||
@@ -92,3 +98,49 @@ func TestFilterAdminListingsBySkinGroupOnly(t *testing.T) {
|
||||
t.Fatalf("expected only listing 2 by gold skin group, got %#v", filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAdminListFiltersSmartRouting(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("打开测试数据库失败: %v", err)
|
||||
}
|
||||
repo := &Repository{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query AdminListQuery
|
||||
wantSQLFrag string
|
||||
}{
|
||||
{
|
||||
name: "12位时间计算的纯数字商品编号路由至rental_listings(listing_no)",
|
||||
query: AdminListQuery{Keyword: "202609040184"},
|
||||
wantSQLFrag: "l.listing_no = ?",
|
||||
},
|
||||
{
|
||||
name: "L前缀商品编号路由至rental_listings(listing_no)",
|
||||
query: AdminListQuery{Keyword: "L20260905100"},
|
||||
wantSQLFrag: "l.listing_no = ?",
|
||||
},
|
||||
{
|
||||
name: "RO前缀订单号路由至EXISTS订单唯一索引",
|
||||
query: AdminListQuery{Keyword: "RO202609051000"},
|
||||
wantSQLFrag: "o.order_no = ?",
|
||||
},
|
||||
{
|
||||
name: "短数字ID路由至主键及关联ID",
|
||||
query: AdminListQuery{Keyword: "12345"},
|
||||
wantSQLFrag: "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 = ?",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt := repo.applyAdminListFilters(db.Table("rental_listings AS l"), tt.query).Find(&[]struct{}{}).Statement
|
||||
sql := stmt.SQL.String()
|
||||
if !strings.Contains(sql, tt.wantSQLFrag) {
|
||||
t.Fatalf("期望 SQL 包含 %q, 实际生成: %s", tt.wantSQLFrag, sql)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -275,9 +275,11 @@ func externalAccountToCreateRequest(
|
||||
"resources": resources,
|
||||
"skin_groups": externalSkinGroups(skins),
|
||||
"online_time_text": onlineTimeText,
|
||||
"ban_record": normalizeBanRecord(item.BanRecord),
|
||||
"common_regions": commonRegions(item.CommonRegion),
|
||||
"remark": remark,
|
||||
// 外部导入没有该字段时按不可凌晨响应处理,避免旧数据误入夜间专区。
|
||||
"early_morning_response": "否",
|
||||
"ban_record": normalizeBanRecord(item.BanRecord),
|
||||
"common_regions": commonRegions(item.CommonRegion),
|
||||
"remark": remark,
|
||||
"import_meta": map[string]any{
|
||||
"uploader_name": uploaderName,
|
||||
"client_upload_time": uploadTime,
|
||||
@@ -382,10 +384,11 @@ func buildExternalListingTitle(rank string, insurance string, hafCoinM float64,
|
||||
|
||||
func serverRegionFromLoginMethod(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
normalized := strings.ToLower(value)
|
||||
switch {
|
||||
case strings.Contains(value, "微信"):
|
||||
case strings.Contains(value, "微信"), strings.HasPrefix(normalized, "vx"), strings.HasPrefix(normalized, "wx"):
|
||||
return "微信"
|
||||
case strings.Contains(strings.ToLower(value), "steam"):
|
||||
case strings.Contains(normalized, "steam"):
|
||||
return "Steam"
|
||||
default:
|
||||
return "QQ"
|
||||
|
||||
@@ -403,53 +403,13 @@ func isNightAvailableSummary(summary map[string]any) bool {
|
||||
if summary == nil {
|
||||
return false
|
||||
}
|
||||
onlineTime, ok := summary["online_time"].(map[string]any)
|
||||
if !ok {
|
||||
// 夜间专区只认号主明确确认的凌晨响应能力,旧数据缺失时默认不进入专区。
|
||||
switch value := summary["early_morning_response"].(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(value) == "是"
|
||||
case bool:
|
||||
return value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
start, okStart := parseTimeMinuteValue(onlineTime["start"])
|
||||
end, okEnd := parseTimeMinuteValue(onlineTime["end"])
|
||||
if !okStart || !okEnd {
|
||||
return false
|
||||
}
|
||||
return timeRangeOverlapsMinutes(start, end, 0, 8*60)
|
||||
}
|
||||
|
||||
func parseTimeMinuteValue(value any) (int, bool) {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
parts := strings.Split(text, ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
hour, err := strconv.Atoi(parts[0])
|
||||
if err != nil || hour < 0 || hour > 23 {
|
||||
return 0, false
|
||||
}
|
||||
minute, err := strconv.Atoi(parts[1])
|
||||
if err != nil || minute < 0 || minute > 59 {
|
||||
return 0, false
|
||||
}
|
||||
return hour*60 + minute, true
|
||||
}
|
||||
|
||||
func timeRangeOverlapsMinutes(start int, end int, targetStart int, targetEnd int) bool {
|
||||
if start == end {
|
||||
return true
|
||||
}
|
||||
for _, segment := range splitMinuteRange(start, end) {
|
||||
if segment[0] <= targetEnd && segment[1] >= targetStart {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitMinuteRange(start int, end int) [][2]int {
|
||||
if start < end {
|
||||
return [][2]int{{start, end}}
|
||||
}
|
||||
return [][2]int{{start, 23*60 + 59}, {0, end}}
|
||||
}
|
||||
|
||||
@@ -178,31 +178,27 @@ func TestSortPublicListingsDefaultUsesShuffleKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNightAvailableSummaryUsesMidnightToEight(t *testing.T) {
|
||||
func TestIsNightAvailableSummaryUsesEarlyMorningResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
want bool
|
||||
name string
|
||||
response any
|
||||
want bool
|
||||
}{
|
||||
{name: "全天可上号", start: "00:00", end: "23:59", want: true},
|
||||
{name: "覆盖夜间新区间", start: "00:00", end: "08:00", want: true},
|
||||
{name: "刚好八点开始", start: "08:00", end: "12:00", want: true},
|
||||
{name: "八点半开始不算夜间", start: "08:30", end: "12:00", want: false},
|
||||
{name: "跨零点覆盖夜间", start: "23:00", end: "02:00", want: true},
|
||||
{name: "旧夜间晚间时段不再命中", start: "22:00", end: "23:00", want: false},
|
||||
{name: "明确响应", response: "是", want: true},
|
||||
{name: "明确不响应", response: "否", want: false},
|
||||
{name: "兼容布尔值", response: true, want: true},
|
||||
{name: "缺失字段", response: nil, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isNightAvailableSummary(map[string]any{
|
||||
"online_time": map[string]any{
|
||||
"start": tt.start,
|
||||
"end": tt.end,
|
||||
},
|
||||
})
|
||||
summary := map[string]any{}
|
||||
if tt.response != nil {
|
||||
summary["early_morning_response"] = tt.response
|
||||
}
|
||||
got := isNightAvailableSummary(summary)
|
||||
if got != tt.want {
|
||||
t.Fatalf("isNightAvailableSummary(%s-%s) = %v, want %v", tt.start, tt.end, got, tt.want)
|
||||
t.Fatalf("isNightAvailableSummary(%#v) = %v, want %v", tt.response, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,12 +66,6 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Admi
|
||||
}, nil
|
||||
}
|
||||
|
||||
countDB := r.applyAdminListFilters(r.db.WithContext(ctx).Table("rental_listings AS l"), query)
|
||||
var total int64
|
||||
if err := countDB.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := r.applyAdminListFilters(r.baseQuery(ctx), query)
|
||||
offset := (page - 1) * pageSize
|
||||
if offset < 0 {
|
||||
@@ -82,6 +76,17 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminListQuery) (*Admi
|
||||
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,
|
||||
@@ -95,17 +100,24 @@ func (r *Repository) applyAdminListFilters(db *gorm.DB, query AdminListQuery) *g
|
||||
db = db.Where("l.owner_id = ?", query.OwnerID)
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
|
||||
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 LIKE ? OR EXISTS (SELECT 1 FROM rental_orders AS o WHERE o.listing_id = l.id AND o.order_no LIKE ?) OR l.id = ? OR l.account_id = ? OR l.owner_id = ?)`,
|
||||
like,
|
||||
like,
|
||||
`(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,
|
||||
@@ -204,22 +216,21 @@ func (r *Repository) findDTO(ctx context.Context, where string, args ...any) (*L
|
||||
}
|
||||
|
||||
func (r *Repository) baseQuery(ctx context.Context) *gorm.DB {
|
||||
uploadSource := r.db.WithContext(ctx).Table("listing_uploads").
|
||||
Select("listing_id, MAX(id) AS upload_id, MAX(NULLIF(source_channel, '')) AS source_channel").
|
||||
Where("listing_id IS NOT NULL").
|
||||
Group("listing_id")
|
||||
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.upload_id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
|
||||
CASE WHEN lu.id IS NULL THEN 0 ELSE 1 END AS is_external_upload,
|
||||
CASE
|
||||
WHEN lu.upload_id IS NULL THEN ?
|
||||
WHEN lu.id IS NULL THEN ?
|
||||
WHEN COALESCE(lu.source_channel, '') = '' THEN ?
|
||||
ELSE lu.source_channel
|
||||
END AS 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 (?) AS lu ON lu.listing_id = l.id", uploadSource)
|
||||
// 逐商品按 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) {
|
||||
@@ -266,3 +277,16 @@ func (r *Repository) nextListingNo(tx *gorm.DB, now time.Time) (string, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -604,6 +604,9 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
|
||||
if req.AssetSummary["online_time_text"] != "08:00 至 01:00" {
|
||||
t.Fatalf("online_time_text = %#v", req.AssetSummary["online_time_text"])
|
||||
}
|
||||
if req.AssetSummary["early_morning_response"] != "否" {
|
||||
t.Fatalf("early_morning_response = %#v, want 否", req.AssetSummary["early_morning_response"])
|
||||
}
|
||||
onlineTime, ok := req.AssetSummary["online_time"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("online_time missing: %#v", req.AssetSummary["online_time"])
|
||||
@@ -640,6 +643,28 @@ func TestExternalAccountToCreateRequestMapsUploadFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRegionFromLoginMethod(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loginMethod string
|
||||
want string
|
||||
}{
|
||||
{name: "QQ账号密码", loginMethod: "QQ账号密码", want: "QQ"},
|
||||
{name: "微信扫码", loginMethod: "微信扫码", want: "微信"},
|
||||
{name: "VX扫码", loginMethod: "VX扫码", want: "微信"},
|
||||
{name: "小写WX扫码", loginMethod: "wx扫码", want: "微信"},
|
||||
{name: "Steam", loginMethod: "Steam令牌", want: "Steam"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := serverRegionFromLoginMethod(tt.loginMethod); got != tt.want {
|
||||
t.Fatalf("serverRegionFromLoginMethod(%q) = %q, want %q", tt.loginMethod, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalAccountToCreateRequestSeparatesPureCoinAndExtraItems(t *testing.T) {
|
||||
req := externalAccountToCreateRequest("客服1", 1772526103000, ExternalAccountData{
|
||||
LoginMethod: "QQ账号密码",
|
||||
|
||||
Reference in New Issue
Block a user