diff --git a/backend/internal/jobs/ordertimeout/job.go b/backend/internal/jobs/ordertimeout/job.go index 35211f9..f8887dc 100644 --- a/backend/internal/jobs/ordertimeout/job.go +++ b/backend/internal/jobs/ordertimeout/job.go @@ -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 diff --git a/backend/internal/jobs/ordertimeout/job_test.go b/backend/internal/jobs/ordertimeout/job_test.go new file mode 100644 index 0000000..696265c --- /dev/null +++ b/backend/internal/jobs/ordertimeout/job_test.go @@ -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) + } + } +} diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index 717eb48..8bbdebf 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -150,7 +150,8 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint ParticipantType: "user", ParticipantID: renterID, Role: "renter", - JoinedAt: now, // 关键: 记录加入时间,用于消息可见性过滤 + JoinedAt: now, + LastReadAt: &now, } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil { @@ -183,17 +184,17 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint return sendSystemMessage(tx, conv.ID, message) } -// RemoveRenterFromListingConversation 移出租客 -func RemoveRenterFromListingConversation(tx *gorm.DB, listingID uint64, renterID uint64) error { +// RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。 +func RemoveRenterFromListingConversation(tx *gorm.DB, listingID uint64, renterID uint64) (bool, error) { // 1. 查找发布群 var conv model.ChatConversation err := tx.Where("listing_id = ?", listingID).First(&conv).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { // 无发布群,跳过 - return nil + return false, nil } - return err + return false, err } // 2. 删除租客参与者记录 @@ -202,16 +203,19 @@ func RemoveRenterFromListingConversation(tx *gorm.DB, listingID uint64, renterID Delete(&model.ChatParticipant{}) if result.Error != nil { - return result.Error + return false, result.Error } // 3. 发系统消息(如果确实删除了) if result.RowsAffected > 0 { message := "订单已结束,租客已退出群聊" - return sendSystemMessage(tx, conv.ID, message) + if err := sendSystemMessage(tx, conv.ID, message); err != nil { + return false, err + } + return true, nil } - return nil + return false, nil } // 辅助函数 diff --git a/backend/internal/modules/chat/message.go b/backend/internal/modules/chat/message.go index 34ae252..94ee54b 100644 --- a/backend/internal/modules/chat/message.go +++ b/backend/internal/modules/chat/message.go @@ -14,15 +14,11 @@ func (r *Repository) Messages(ctx context.Context, principal Principal, conversa page, pageSize = normalizePagination(page, pageSize) db := r.db.WithContext(ctx) - var participant *model.ChatParticipant - // 管理员可以查看任意会话的消息,普通用户需要是 participant if principal.Type != "admin" { - p, err := r.findParticipant(db, principal, conversationID, false) - if err != nil { + if _, err := r.findParticipant(db, principal, conversationID, false); err != nil { return nil, err } - participant = p } else { // 管理员需要验证会话存在 var count int64 @@ -37,11 +33,6 @@ func (r *Repository) Messages(ctx context.Context, principal Principal, conversa // 构建查询 query := db.Model(&model.ChatMessage{}).Where("conversation_id = ?", conversationID) - // 租客只能看到加入时间之后的消息 - if principal.Type == "user" && participant != nil && participant.Role == "renter" { - query = query.Where("created_at >= ?", participant.JoinedAt) - } - var total int64 if err := query.Count(&total).Error; err != nil { return nil, err diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go index c2341f5..fc5f707 100644 --- a/backend/internal/modules/order/checkout_finalize.go +++ b/backend/internal/modules/order/checkout_finalize.go @@ -4,7 +4,6 @@ import ( "time" "hfb_sys/backend/internal/model" - "hfb_sys/backend/internal/modules/chat" "hfb_sys/backend/internal/modules/notification" "hfb_sys/backend/internal/modules/wallet" @@ -40,12 +39,6 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che return nil, err } - // 订单完成,移出租客 - if err := chat.RemoveRenterFromListingConversation(tx, listing.ID, order.RenterID); err != nil { - // 移出失败不阻塞订单完成,仅记录日志 - // TODO: 添加日志 - } - return refund, nil } diff --git a/backend/internal/modules/order/lifecycle.go b/backend/internal/modules/order/lifecycle.go index c2201bf..60ade5b 100644 --- a/backend/internal/modules/order/lifecycle.go +++ b/backend/internal/modules/order/lifecycle.go @@ -244,8 +244,10 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) } beforeStatus := order.Status + now := time.Now() order.Status = orderStatusCancelled order.HandoffStatus = handoffStatusCancelled + order.SettledAt = &now orderID := order.ID if beforeStatus == orderStatusPendingHandoff { totalCent := order.RentAmountCent + order.DepositAmountCent @@ -280,13 +282,6 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64) return err } - // 订单取消,移出租客(仅付款后取消需要移出) - if beforeStatus == orderStatusPendingHandoff { - if err := chat.RemoveRenterFromListingConversation(tx, listing.ID, order.RenterID); err != nil { - // 移出失败不阻塞订单取消 - } - } - if err := tx.Save(&order).Error; err != nil { return err } diff --git a/backend/internal/modules/systemconfig/repository.go b/backend/internal/modules/systemconfig/repository.go index 24c7db4..7223039 100644 --- a/backend/internal/modules/systemconfig/repository.go +++ b/backend/internal/modules/systemconfig/repository.go @@ -38,6 +38,7 @@ var defaultConfigs = []defaultConfig{ {Key: "chat.default_support_admin_id", Value: "2", Description: "默认客服 ID(必须是启用状态的客服角色)"}, {Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"}, {Key: "chat.listing_group_welcome", Value: "欢迎加入账号群!请号主扫描下方二维码加入企业微信群,方便客服与您及时联系。", Description: "发布群创建后自动发送的欢迎语"}, + {Key: "chat.renter_retention_days_after_order_end", Value: "5", Description: "订单结束后租客保留在发布群的天数(3-7)"}, {Key: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"}, {Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"}, {Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"}, @@ -48,6 +49,7 @@ var adminVisibleConfigKeys = []string{ "chat.auto_welcome_message", "chat.default_support_admin_id", "chat.listing_group_welcome", + "chat.renter_retention_days_after_order_end", "handoff.owner_return_confirm_timeout_minutes", "handoff.owner_submit_timeout_minutes", "handoff.renter_confirm_timeout_minutes", diff --git a/backend/migrations/000011_chat_support_groups.sql b/backend/migrations/000011_chat_support_groups.sql index 37f2227..b649d70 100644 --- a/backend/migrations/000011_chat_support_groups.sql +++ b/backend/migrations/000011_chat_support_groups.sql @@ -54,6 +54,11 @@ WHERE g.code IN ('owner_onboarding', 'renter_handoff') AND r.code = 'cs' AND au.status = 'active'; +INSERT INTO system_configs (`key`, `value`, description) VALUES +('chat.renter_retention_days_after_order_end', '5', '订单结束后租客保留在发布群的天数(3-7)') +ON DUPLICATE KEY UPDATE + description = VALUES(description); + -- +goose StatementEnd -- +goose Down @@ -65,5 +70,6 @@ DELETE rp FROM role_permissions rp JOIN permissions p ON p.id = rp.permission_id WHERE p.code = 'chat:manage'; DELETE FROM permissions WHERE code = 'chat:manage'; +DELETE FROM system_configs WHERE `key` = 'chat.renter_retention_days_after_order_end'; -- +goose StatementEnd diff --git a/frontend/src/features/admin/views/AdminSystemConfigsView.vue b/frontend/src/features/admin/views/AdminSystemConfigsView.vue index cf90650..5c5b368 100644 --- a/frontend/src/features/admin/views/AdminSystemConfigsView.vue +++ b/frontend/src/features/admin/views/AdminSystemConfigsView.vue @@ -102,6 +102,9 @@ const autoWelcomeConfig = computed( const listingGroupWelcomeConfig = computed( () => configs.value.find(item => item.key === 'chat.listing_group_welcome') || null ) +const renterRetentionConfig = computed( + () => configs.value.find(item => item.key === 'chat.renter_retention_days_after_order_end') || null +) const regularConfigs = computed(() => configs.value.filter( @@ -114,7 +117,8 @@ const regularConfigs = computed(() => item.key !== 'order.agreements' && item.key !== 'profile.post_rental_notice' && item.key !== 'chat.auto_welcome_message' && - item.key !== 'chat.listing_group_welcome' + item.key !== 'chat.listing_group_welcome' && + item.key !== 'chat.renter_retention_days_after_order_end' ) ) @@ -200,6 +204,8 @@ function openEdit(row: SystemConfig) { agreementsVisible.value = true } else if (row.key === 'profile.post_rental_notice') { postRentalNoticeVisible.value = true + } else if (row.key === 'chat.renter_retention_days_after_order_end') { + generalVisible.value = true } else { generalVisible.value = true } @@ -560,6 +566,39 @@ function formatConfigValue(row: SystemConfig) { + + + + Chat Retention + 发布群租客保留天数 + 订单结束后,租客在发布群中保留的天数,超时后由后台任务自动移出。 + + 编辑天数 + + + + {{ renterRetentionConfig.value }} + 当前天数 + + + 范围 + 3-7 天 + + + 任务 + 订单结束后自动清退 + + + 键 + {{ renterRetentionConfig.key }} + + + 更新 + {{ formatHomeConfigStatus(renterRetentionConfig, '未初始化') }} + + + + diff --git a/frontend/src/shared/utils/systemConfigOptions.ts b/frontend/src/shared/utils/systemConfigOptions.ts index 4ebd6cb..ce7217c 100644 --- a/frontend/src/shared/utils/systemConfigOptions.ts +++ b/frontend/src/shared/utils/systemConfigOptions.ts @@ -53,6 +53,11 @@ export const systemConfigSelectOptions: Record = { { label: '管理员 ID 2', value: '2' }, { label: '管理员 ID 3', value: '3' }, ], + 'chat.renter_retention_days_after_order_end': [ + { label: '3 天', value: '3' }, + { label: '5 天', value: '5' }, + { label: '7 天', value: '7' }, + ], } export function getSystemConfigSelectOptions(key: string) {
Chat Retention