优化发布群租客清退与历史消息
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/chat"
|
||||
"hfb_sys/backend/internal/modules/notification"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -21,6 +22,12 @@ import (
|
||||
|
||||
const orderTimeoutLockKey = "hfb:job:ordertimeout:lock"
|
||||
|
||||
const (
|
||||
defaultRenterRetentionDaysAfterOrderEnd = 5
|
||||
minRenterRetentionDaysAfterOrderEnd = 3
|
||||
maxRenterRetentionDaysAfterOrderEnd = 7
|
||||
)
|
||||
|
||||
type Job struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
@@ -35,6 +42,7 @@ type thresholds struct {
|
||||
RenterConfirmTimeoutMinutes int
|
||||
ReturnOverdueGraceMinutes int
|
||||
OwnerReturnConfirmTimeoutMinutes int
|
||||
RenterRetentionDaysAfterOrderEnd int
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Job {
|
||||
@@ -130,6 +138,7 @@ func (j *Job) run(ctx context.Context) {
|
||||
j.handleRenterConfirmTimeout,
|
||||
j.handleReturnOverdue,
|
||||
j.handleOwnerReturnConfirmTimeout,
|
||||
j.handleEndedOrderRenterRetention,
|
||||
}
|
||||
total := 0
|
||||
for _, handler := range handlers {
|
||||
@@ -152,6 +161,7 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) {
|
||||
RenterConfirmTimeoutMinutes: 30,
|
||||
ReturnOverdueGraceMinutes: 10,
|
||||
OwnerReturnConfirmTimeoutMinutes: 120,
|
||||
RenterRetentionDaysAfterOrderEnd: defaultRenterRetentionDaysAfterOrderEnd,
|
||||
}
|
||||
var rows []model.SystemConfig
|
||||
err := j.db.WithContext(ctx).
|
||||
@@ -161,6 +171,7 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) {
|
||||
"order.pending_payment_timeout_minutes",
|
||||
"order.return_overdue_grace_minutes",
|
||||
"handoff.owner_return_confirm_timeout_minutes",
|
||||
"chat.renter_retention_days_after_order_end",
|
||||
}).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
@@ -182,11 +193,23 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) {
|
||||
cfg.ReturnOverdueGraceMinutes = value
|
||||
case "handoff.owner_return_confirm_timeout_minutes":
|
||||
cfg.OwnerReturnConfirmTimeoutMinutes = value
|
||||
case "chat.renter_retention_days_after_order_end":
|
||||
cfg.RenterRetentionDaysAfterOrderEnd = clampRenterRetentionDays(value)
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func clampRenterRetentionDays(value int) int {
|
||||
if value < minRenterRetentionDaysAfterOrderEnd {
|
||||
return minRenterRetentionDaysAfterOrderEnd
|
||||
}
|
||||
if value > maxRenterRetentionDaysAfterOrderEnd {
|
||||
return maxRenterRetentionDaysAfterOrderEnd
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (j *Job) handlePendingPaymentTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
|
||||
if cfg.PendingPaymentTimeoutMinutes <= 0 {
|
||||
return 0, nil
|
||||
@@ -483,6 +506,71 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (j *Job) handleEndedOrderRenterRetention(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
|
||||
retentionDays := clampRenterRetentionDays(cfg.RenterRetentionDaysAfterOrderEnd)
|
||||
deadline := now.AddDate(0, 0, -retentionDays)
|
||||
var rows []model.RentalOrder
|
||||
err := j.db.WithContext(ctx).
|
||||
Table("rental_orders AS o").
|
||||
Select("o.*").
|
||||
Joins("JOIN chat_conversations AS c ON c.listing_id = o.listing_id AND c.type = ?", chat.ConversationTypeListingGroup).
|
||||
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id AND cp.participant_type = ? AND cp.participant_id = o.renter_id AND cp.role = ?", "user", "renter").
|
||||
Where("o.status IN ?", []string{"completed", "cancelled", "closed"}).
|
||||
Where("o.settled_at IS NOT NULL AND o.settled_at <= ?", deadline).
|
||||
Order("o.id ASC").
|
||||
Limit(100).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, row := range rows {
|
||||
removed := false
|
||||
if err := j.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var order model.RentalOrder
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, row.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !isEndedOrderStatus(order.Status) {
|
||||
return nil
|
||||
}
|
||||
if order.SettledAt == nil || order.SettledAt.After(deadline) {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
removed, err = chat.RemoveRenterFromListingConversation(tx, order.ListingID, order.RenterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !removed {
|
||||
return nil
|
||||
}
|
||||
return appendAuditLog(tx, "chat.renter_retention.remove", order.ID, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"order_no": order.OrderNo,
|
||||
"listing_id": order.ListingID,
|
||||
"renter_id": order.RenterID,
|
||||
"retention_days": retentionDays,
|
||||
})
|
||||
}); err != nil {
|
||||
return count, err
|
||||
}
|
||||
if removed {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func isEndedOrderStatus(status string) bool {
|
||||
switch status {
|
||||
case "completed", "cancelled", "closed":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (j *Job) updateOrder(ctx context.Context, orderID uint64, action string, fn func(*gorm.DB, *model.RentalOrder) (string, error)) error {
|
||||
return j.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var order model.RentalOrder
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package ordertimeout
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/chat"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func setupOrderTimeoutTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("无法创建测试数据库: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.RentalOrder{},
|
||||
&model.ChatConversation{},
|
||||
&model.ChatParticipant{},
|
||||
&model.ChatMessage{},
|
||||
&model.SystemConfig{},
|
||||
&model.AuditLog{},
|
||||
); err != nil {
|
||||
t.Fatalf("数据库迁移失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestHandleEndedOrderRenterRetention(t *testing.T) {
|
||||
db := setupOrderTimeoutTestDB(t)
|
||||
job := New(db, nil, zap.NewNop())
|
||||
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
oldSettledAt := now.AddDate(0, 0, -6)
|
||||
recentSettledAt := now.AddDate(0, 0, -2)
|
||||
oldOrder := model.RentalOrder{
|
||||
OrderNo: "ORDER-OLD",
|
||||
ListingID: 101,
|
||||
AccountID: 201,
|
||||
OwnerID: 301,
|
||||
RenterID: 401,
|
||||
Status: "completed",
|
||||
SettledAt: &oldSettledAt,
|
||||
}
|
||||
recentOrder := model.RentalOrder{
|
||||
OrderNo: "ORDER-RECENT",
|
||||
ListingID: 102,
|
||||
AccountID: 202,
|
||||
OwnerID: 302,
|
||||
RenterID: 402,
|
||||
Status: "completed",
|
||||
SettledAt: &recentSettledAt,
|
||||
}
|
||||
if err := db.Create(&oldOrder).Error; err != nil {
|
||||
t.Fatalf("创建旧订单失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&recentOrder).Error; err != nil {
|
||||
t.Fatalf("创建新订单失败: %v", err)
|
||||
}
|
||||
|
||||
oldConv := model.ChatConversation{
|
||||
ListingID: &oldOrder.ListingID,
|
||||
Type: chat.ConversationTypeListingGroup,
|
||||
Title: "旧发布群",
|
||||
Status: "active",
|
||||
}
|
||||
recentConv := model.ChatConversation{
|
||||
ListingID: &recentOrder.ListingID,
|
||||
Type: chat.ConversationTypeListingGroup,
|
||||
Title: "新发布群",
|
||||
Status: "active",
|
||||
}
|
||||
if err := db.Create(&oldConv).Error; err != nil {
|
||||
t.Fatalf("创建旧会话失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&recentConv).Error; err != nil {
|
||||
t.Fatalf("创建新会话失败: %v", err)
|
||||
}
|
||||
participants := []model.ChatParticipant{
|
||||
{ConversationID: oldConv.ID, ParticipantType: "user", ParticipantID: oldOrder.RenterID, Role: "renter", JoinedAt: oldSettledAt},
|
||||
{ConversationID: recentConv.ID, ParticipantType: "user", ParticipantID: recentOrder.RenterID, Role: "renter", JoinedAt: recentSettledAt},
|
||||
}
|
||||
if err := db.Create(&participants).Error; err != nil {
|
||||
t.Fatalf("创建成员失败: %v", err)
|
||||
}
|
||||
|
||||
count, err := job.handleEndedOrderRenterRetention(t.Context(), now, thresholds{
|
||||
RenterRetentionDaysAfterOrderEnd: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("清退任务失败: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("清退数量 = %d, want 1", count)
|
||||
}
|
||||
|
||||
var oldCount int64
|
||||
if err := db.Model(&model.ChatParticipant{}).
|
||||
Where("conversation_id = ? AND participant_id = ? AND role = ?", oldConv.ID, oldOrder.RenterID, "renter").
|
||||
Count(&oldCount).Error; err != nil {
|
||||
t.Fatalf("统计旧租客失败: %v", err)
|
||||
}
|
||||
if oldCount != 0 {
|
||||
t.Fatalf("旧租客成员数 = %d, want 0", oldCount)
|
||||
}
|
||||
|
||||
var recentCount int64
|
||||
if err := db.Model(&model.ChatParticipant{}).
|
||||
Where("conversation_id = ? AND participant_id = ? AND role = ?", recentConv.ID, recentOrder.RenterID, "renter").
|
||||
Count(&recentCount).Error; err != nil {
|
||||
t.Fatalf("统计新租客失败: %v", err)
|
||||
}
|
||||
if recentCount != 1 {
|
||||
t.Fatalf("新租客成员数 = %d, want 1", recentCount)
|
||||
}
|
||||
|
||||
var auditCount int64
|
||||
if err := db.Model(&model.AuditLog{}).
|
||||
Where("action = ?", "chat.renter_retention.remove").
|
||||
Count(&auditCount).Error; err != nil {
|
||||
t.Fatalf("统计清退审计失败: %v", err)
|
||||
}
|
||||
if auditCount != 1 {
|
||||
t.Fatalf("清退审计数 = %d, want 1", auditCount)
|
||||
}
|
||||
|
||||
count, err = job.handleEndedOrderRenterRetention(t.Context(), now, thresholds{
|
||||
RenterRetentionDaysAfterOrderEnd: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("重复清退任务失败: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("重复清退数量 = %d, want 0", count)
|
||||
}
|
||||
|
||||
if err := db.Model(&model.AuditLog{}).
|
||||
Where("action = ?", "chat.renter_retention.remove").
|
||||
Count(&auditCount).Error; err != nil {
|
||||
t.Fatalf("重复统计清退审计失败: %v", err)
|
||||
}
|
||||
if auditCount != 1 {
|
||||
t.Fatalf("重复清退审计数 = %d, want 1", auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampRenterRetentionDays(t *testing.T) {
|
||||
tests := []struct {
|
||||
value int
|
||||
want int
|
||||
}{
|
||||
{value: 1, want: 3},
|
||||
{value: 5, want: 5},
|
||||
{value: 9, want: 7},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := clampRenterRetentionDays(tt.value); got != tt.want {
|
||||
t.Fatalf("clampRenterRetentionDays(%d) = %d, want %d", tt.value, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user