合并 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:
@@ -19,8 +19,9 @@ import (
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
encryptor crypto.Encryptor
|
||||
db *gorm.DB
|
||||
encryptor crypto.Encryptor
|
||||
statusChangeNotifier func(userID uint64)
|
||||
}
|
||||
|
||||
type AuditMeta = auditlog.Meta
|
||||
@@ -33,16 +34,16 @@ func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository {
|
||||
return &Repository{db: db, encryptor: encryptor}
|
||||
}
|
||||
|
||||
// SetStatusChangeNotifier 设置状态变更后的会话撤销通知。
|
||||
func (r *Repository) SetStatusChangeNotifier(notifier func(userID uint64)) {
|
||||
r.statusChangeNotifier = notifier
|
||||
}
|
||||
|
||||
func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
|
||||
growthConfig, err := rentergrowth.ConfigForTx(r.db.WithContext(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var total int64
|
||||
countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query, growthConfig)
|
||||
if err := countTx.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
var rows []userRow
|
||||
listTx := r.db.WithContext(ctx).Table("users AS u").
|
||||
@@ -66,6 +67,18 @@ func (r *Repository) List(ctx context.Context, page, pageSize int, query ListQue
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var total int64
|
||||
// 首页查询且返回条数小于 pageSize 时,总数等于当前返回条数,彻底免除第二次 COUNT(*) 扫描
|
||||
if page == 1 && len(rows) < pageSize {
|
||||
total = int64(len(rows))
|
||||
} else {
|
||||
countTx := applyUserFilter(r.db.WithContext(ctx).Table("users AS u"), query, growthConfig)
|
||||
if err := countTx.Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]UserDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, row.toDTO(growthConfig))
|
||||
@@ -86,11 +99,15 @@ func applyUserFilter(tx *gorm.DB, query ListQuery, growthConfig rentergrowth.Con
|
||||
}
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
// 关键词为纯数字时一并按用户 ID 精确匹配
|
||||
if id, err := strconv.ParseUint(keyword, 10, 64); err == nil {
|
||||
if len(keyword) == 11 && keyword[0] == '1' && isAllDigits(keyword) {
|
||||
// 11 位手机号:命中 users(phone) 唯一索引
|
||||
tx = tx.Where("u.phone = ?", keyword)
|
||||
} else if id, err := strconv.ParseUint(keyword, 10, 64); err == nil && id > 0 && keyword[0] != '0' && id < 100000000 {
|
||||
// 纯数字 ID:一并按用户 ID 精确匹配
|
||||
like := "%" + keyword + "%"
|
||||
tx = tx.Where("u.id = ? OR u.phone LIKE ? OR u.nickname LIKE ?", id, like, like)
|
||||
} else {
|
||||
like := "%" + keyword + "%"
|
||||
tx = tx.Where("u.phone LIKE ? OR u.nickname LIKE ?", like, like)
|
||||
}
|
||||
}
|
||||
@@ -207,6 +224,7 @@ func (r *Repository) AdjustGrowthPoints(ctx context.Context, adminID uint64, use
|
||||
}
|
||||
|
||||
func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID uint64, status string, riskStatus string, action string, reason string, meta AuditMeta) (*UserDTO, error) {
|
||||
statusChanged := false
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var user model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, userID).Error; err != nil {
|
||||
@@ -214,27 +232,33 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui
|
||||
}
|
||||
beforeStatus := user.Status
|
||||
beforeRisk := user.RiskStatus
|
||||
beforeTokenVersion := user.TokenVersion
|
||||
user.Status = status
|
||||
user.RiskStatus = riskStatus
|
||||
if user.TokenVersion < 1 {
|
||||
user.TokenVersion = 1
|
||||
if beforeStatus != status {
|
||||
user.TokenVersion++
|
||||
statusChanged = true
|
||||
}
|
||||
user.TokenVersion++
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAuditLog(tx, adminID, action, user.ID, meta, map[string]any{
|
||||
"user_id": user.ID,
|
||||
"reason": reason,
|
||||
"before_status": beforeStatus,
|
||||
"after_status": status,
|
||||
"before_risk_status": beforeRisk,
|
||||
"after_risk_status": riskStatus,
|
||||
"user_id": user.ID,
|
||||
"reason": reason,
|
||||
"before_status": beforeStatus,
|
||||
"after_status": status,
|
||||
"before_risk_status": beforeRisk,
|
||||
"after_risk_status": riskStatus,
|
||||
"before_token_version": beforeTokenVersion,
|
||||
"after_token_version": user.TokenVersion,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if statusChanged && r.statusChangeNotifier != nil {
|
||||
r.statusChangeNotifier(userID)
|
||||
}
|
||||
return r.Find(ctx, userID)
|
||||
}
|
||||
|
||||
@@ -555,3 +579,15 @@ func appendAuditLog(tx *gorm.DB, actorID uint64, action string, bizID uint64, me
|
||||
func IsNotFound(err error) bool {
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -293,6 +293,39 @@ func TestAdjustWalletInsufficientBalance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreezeAndUnfreezeBumpUserTokenVersion(t *testing.T) {
|
||||
db := setupAdminUserTestDB(t)
|
||||
// 显式给出版本起点,避免 GORM 对零值字段应用 default:1 造成歧义。
|
||||
user := model.User{Phone: "13900000006", Status: "active", TokenVersion: 1}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败:%v", err)
|
||||
}
|
||||
|
||||
repo := NewRepository(db)
|
||||
got, err := repo.Freeze(t.Context(), 66, user.ID, FreezeRequest{Reason: "风险处置"}, AuditMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("冻结用户失败:%v", err)
|
||||
}
|
||||
var saved model.User
|
||||
if err := db.First(&saved, user.ID).Error; err != nil {
|
||||
t.Fatalf("查询冻结用户失败:%v", err)
|
||||
}
|
||||
if got.Status != "frozen" || saved.TokenVersion != 2 {
|
||||
t.Fatalf("冻结后状态/版本 = %s/%d, want frozen/2", got.Status, saved.TokenVersion)
|
||||
}
|
||||
|
||||
got, err = repo.Unfreeze(t.Context(), 66, user.ID, AuditMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("解冻用户失败:%v", err)
|
||||
}
|
||||
if err := db.First(&saved, user.ID).Error; err != nil {
|
||||
t.Fatalf("查询解冻用户失败:%v", err)
|
||||
}
|
||||
if got.Status != "active" || saved.TokenVersion != 3 {
|
||||
t.Fatalf("解冻后状态/版本 = %s/%d, want active/3", got.Status, saved.TokenVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func setupAdminUserTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
@@ -37,6 +37,13 @@ func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
// SetSessionRevoker 设置用户会话撤销回调,例如关闭用户的实时连接。
|
||||
func (s *Service) SetSessionRevoker(revoker func(userID uint64)) {
|
||||
if s.repo != nil {
|
||||
s.repo.SetStatusChangeNotifier(revoker)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, page, pageSize int, query ListQuery) (*PaginatedResult, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
Reference in New Issue
Block a user