优化发布群租客清退与历史消息
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
"hfb_sys/backend/internal/modules/chat"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@@ -21,6 +22,12 @@ import (
|
|||||||
|
|
||||||
const orderTimeoutLockKey = "hfb:job:ordertimeout:lock"
|
const orderTimeoutLockKey = "hfb:job:ordertimeout:lock"
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultRenterRetentionDaysAfterOrderEnd = 5
|
||||||
|
minRenterRetentionDaysAfterOrderEnd = 3
|
||||||
|
maxRenterRetentionDaysAfterOrderEnd = 7
|
||||||
|
)
|
||||||
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
redis *redis.Client
|
redis *redis.Client
|
||||||
@@ -35,6 +42,7 @@ type thresholds struct {
|
|||||||
RenterConfirmTimeoutMinutes int
|
RenterConfirmTimeoutMinutes int
|
||||||
ReturnOverdueGraceMinutes int
|
ReturnOverdueGraceMinutes int
|
||||||
OwnerReturnConfirmTimeoutMinutes int
|
OwnerReturnConfirmTimeoutMinutes int
|
||||||
|
RenterRetentionDaysAfterOrderEnd int
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Job {
|
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.handleRenterConfirmTimeout,
|
||||||
j.handleReturnOverdue,
|
j.handleReturnOverdue,
|
||||||
j.handleOwnerReturnConfirmTimeout,
|
j.handleOwnerReturnConfirmTimeout,
|
||||||
|
j.handleEndedOrderRenterRetention,
|
||||||
}
|
}
|
||||||
total := 0
|
total := 0
|
||||||
for _, handler := range handlers {
|
for _, handler := range handlers {
|
||||||
@@ -152,6 +161,7 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) {
|
|||||||
RenterConfirmTimeoutMinutes: 30,
|
RenterConfirmTimeoutMinutes: 30,
|
||||||
ReturnOverdueGraceMinutes: 10,
|
ReturnOverdueGraceMinutes: 10,
|
||||||
OwnerReturnConfirmTimeoutMinutes: 120,
|
OwnerReturnConfirmTimeoutMinutes: 120,
|
||||||
|
RenterRetentionDaysAfterOrderEnd: defaultRenterRetentionDaysAfterOrderEnd,
|
||||||
}
|
}
|
||||||
var rows []model.SystemConfig
|
var rows []model.SystemConfig
|
||||||
err := j.db.WithContext(ctx).
|
err := j.db.WithContext(ctx).
|
||||||
@@ -161,6 +171,7 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) {
|
|||||||
"order.pending_payment_timeout_minutes",
|
"order.pending_payment_timeout_minutes",
|
||||||
"order.return_overdue_grace_minutes",
|
"order.return_overdue_grace_minutes",
|
||||||
"handoff.owner_return_confirm_timeout_minutes",
|
"handoff.owner_return_confirm_timeout_minutes",
|
||||||
|
"chat.renter_retention_days_after_order_end",
|
||||||
}).
|
}).
|
||||||
Find(&rows).Error
|
Find(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -182,11 +193,23 @@ func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) {
|
|||||||
cfg.ReturnOverdueGraceMinutes = value
|
cfg.ReturnOverdueGraceMinutes = value
|
||||||
case "handoff.owner_return_confirm_timeout_minutes":
|
case "handoff.owner_return_confirm_timeout_minutes":
|
||||||
cfg.OwnerReturnConfirmTimeoutMinutes = value
|
cfg.OwnerReturnConfirmTimeoutMinutes = value
|
||||||
|
case "chat.renter_retention_days_after_order_end":
|
||||||
|
cfg.RenterRetentionDaysAfterOrderEnd = clampRenterRetentionDays(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cfg, nil
|
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) {
|
func (j *Job) handlePendingPaymentTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) {
|
||||||
if cfg.PendingPaymentTimeoutMinutes <= 0 {
|
if cfg.PendingPaymentTimeoutMinutes <= 0 {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@@ -483,6 +506,71 @@ func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time
|
|||||||
return count, nil
|
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 {
|
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 {
|
return j.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var order model.RentalOrder
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,7 +150,8 @@ func AddRenterToListingConversation(tx *gorm.DB, listingID uint64, renterID uint
|
|||||||
ParticipantType: "user",
|
ParticipantType: "user",
|
||||||
ParticipantID: renterID,
|
ParticipantID: renterID,
|
||||||
Role: "renter",
|
Role: "renter",
|
||||||
JoinedAt: now, // 关键: 记录加入时间,用于消息可见性过滤
|
JoinedAt: now,
|
||||||
|
LastReadAt: &now,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil {
|
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)
|
return sendSystemMessage(tx, conv.ID, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveRenterFromListingConversation 移出租客
|
// RemoveRenterFromListingConversation 移出租客,返回是否真的删除了租客成员记录。
|
||||||
func RemoveRenterFromListingConversation(tx *gorm.DB, listingID uint64, renterID uint64) error {
|
func RemoveRenterFromListingConversation(tx *gorm.DB, listingID uint64, renterID uint64) (bool, error) {
|
||||||
// 1. 查找发布群
|
// 1. 查找发布群
|
||||||
var conv model.ChatConversation
|
var conv model.ChatConversation
|
||||||
err := tx.Where("listing_id = ?", listingID).First(&conv).Error
|
err := tx.Where("listing_id = ?", listingID).First(&conv).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
// 无发布群,跳过
|
// 无发布群,跳过
|
||||||
return nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 删除租客参与者记录
|
// 2. 删除租客参与者记录
|
||||||
@@ -202,16 +203,19 @@ func RemoveRenterFromListingConversation(tx *gorm.DB, listingID uint64, renterID
|
|||||||
Delete(&model.ChatParticipant{})
|
Delete(&model.ChatParticipant{})
|
||||||
|
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return false, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 发系统消息(如果确实删除了)
|
// 3. 发系统消息(如果确实删除了)
|
||||||
if result.RowsAffected > 0 {
|
if result.RowsAffected > 0 {
|
||||||
message := "订单已结束,租客已退出群聊"
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 辅助函数
|
// 辅助函数
|
||||||
|
|||||||
@@ -14,15 +14,11 @@ func (r *Repository) Messages(ctx context.Context, principal Principal, conversa
|
|||||||
page, pageSize = normalizePagination(page, pageSize)
|
page, pageSize = normalizePagination(page, pageSize)
|
||||||
db := r.db.WithContext(ctx)
|
db := r.db.WithContext(ctx)
|
||||||
|
|
||||||
var participant *model.ChatParticipant
|
|
||||||
|
|
||||||
// 管理员可以查看任意会话的消息,普通用户需要是 participant
|
// 管理员可以查看任意会话的消息,普通用户需要是 participant
|
||||||
if principal.Type != "admin" {
|
if principal.Type != "admin" {
|
||||||
p, err := r.findParticipant(db, principal, conversationID, false)
|
if _, err := r.findParticipant(db, principal, conversationID, false); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
participant = p
|
|
||||||
} else {
|
} else {
|
||||||
// 管理员需要验证会话存在
|
// 管理员需要验证会话存在
|
||||||
var count int64
|
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)
|
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
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
if err := query.Count(&total).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/chat"
|
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
"hfb_sys/backend/internal/modules/wallet"
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
|
|
||||||
@@ -40,12 +39,6 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 订单完成,移出租客
|
|
||||||
if err := chat.RemoveRenterFromListingConversation(tx, listing.ID, order.RenterID); err != nil {
|
|
||||||
// 移出失败不阻塞订单完成,仅记录日志
|
|
||||||
// TODO: 添加日志
|
|
||||||
}
|
|
||||||
|
|
||||||
return refund, nil
|
return refund, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -244,8 +244,10 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64)
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeStatus := order.Status
|
beforeStatus := order.Status
|
||||||
|
now := time.Now()
|
||||||
order.Status = orderStatusCancelled
|
order.Status = orderStatusCancelled
|
||||||
order.HandoffStatus = handoffStatusCancelled
|
order.HandoffStatus = handoffStatusCancelled
|
||||||
|
order.SettledAt = &now
|
||||||
orderID := order.ID
|
orderID := order.ID
|
||||||
if beforeStatus == orderStatusPendingHandoff {
|
if beforeStatus == orderStatusPendingHandoff {
|
||||||
totalCent := order.RentAmountCent + order.DepositAmountCent
|
totalCent := order.RentAmountCent + order.DepositAmountCent
|
||||||
@@ -280,13 +282,6 @@ func (r *Repository) Cancel(ctx context.Context, userID uint64, orderID uint64)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 订单取消,移出租客(仅付款后取消需要移出)
|
|
||||||
if beforeStatus == orderStatusPendingHandoff {
|
|
||||||
if err := chat.RemoveRenterFromListingConversation(tx, listing.ID, order.RenterID); err != nil {
|
|
||||||
// 移出失败不阻塞订单取消
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Save(&order).Error; err != nil {
|
if err := tx.Save(&order).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ var defaultConfigs = []defaultConfig{
|
|||||||
{Key: "chat.default_support_admin_id", Value: "2", Description: "默认客服 ID(必须是启用状态的客服角色)"},
|
{Key: "chat.default_support_admin_id", Value: "2", Description: "默认客服 ID(必须是启用状态的客服角色)"},
|
||||||
{Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"},
|
{Key: "chat.auto_welcome_message", Value: "欢迎加入订单群聊!如有任何问题,请随时沟通。", Description: "建群后自动发送的欢迎话术"},
|
||||||
{Key: "chat.listing_group_welcome", 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: homeAnnouncementsConfigKey, Value: defaultHomeAnnouncementsConfigValue(), Description: "移动端首页公告 JSON 数组"},
|
||||||
{Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"},
|
{Key: homeBannersConfigKey, Value: defaultHomeBannersConfigValue(), Description: "移动端首页轮播图 JSON 数组"},
|
||||||
{Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"},
|
{Key: publishOptionsConfigKey, Value: defaultPublishOptionsConfigValue(), Description: "发布页选项配置 JSON"},
|
||||||
@@ -48,6 +49,7 @@ var adminVisibleConfigKeys = []string{
|
|||||||
"chat.auto_welcome_message",
|
"chat.auto_welcome_message",
|
||||||
"chat.default_support_admin_id",
|
"chat.default_support_admin_id",
|
||||||
"chat.listing_group_welcome",
|
"chat.listing_group_welcome",
|
||||||
|
"chat.renter_retention_days_after_order_end",
|
||||||
"handoff.owner_return_confirm_timeout_minutes",
|
"handoff.owner_return_confirm_timeout_minutes",
|
||||||
"handoff.owner_submit_timeout_minutes",
|
"handoff.owner_submit_timeout_minutes",
|
||||||
"handoff.renter_confirm_timeout_minutes",
|
"handoff.renter_confirm_timeout_minutes",
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ WHERE g.code IN ('owner_onboarding', 'renter_handoff')
|
|||||||
AND r.code = 'cs'
|
AND r.code = 'cs'
|
||||||
AND au.status = 'active';
|
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 StatementEnd
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
@@ -65,5 +70,6 @@ DELETE rp FROM role_permissions rp
|
|||||||
JOIN permissions p ON p.id = rp.permission_id
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
WHERE p.code = 'chat:manage';
|
WHERE p.code = 'chat:manage';
|
||||||
DELETE FROM permissions WHERE 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
|
-- +goose StatementEnd
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ const autoWelcomeConfig = computed(
|
|||||||
const listingGroupWelcomeConfig = computed(
|
const listingGroupWelcomeConfig = computed(
|
||||||
() => configs.value.find(item => item.key === 'chat.listing_group_welcome') || null
|
() => 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(() =>
|
const regularConfigs = computed(() =>
|
||||||
configs.value.filter(
|
configs.value.filter(
|
||||||
@@ -114,7 +117,8 @@ const regularConfigs = computed(() =>
|
|||||||
item.key !== 'order.agreements' &&
|
item.key !== 'order.agreements' &&
|
||||||
item.key !== 'profile.post_rental_notice' &&
|
item.key !== 'profile.post_rental_notice' &&
|
||||||
item.key !== 'chat.auto_welcome_message' &&
|
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
|
agreementsVisible.value = true
|
||||||
} else if (row.key === 'profile.post_rental_notice') {
|
} else if (row.key === 'profile.post_rental_notice') {
|
||||||
postRentalNoticeVisible.value = true
|
postRentalNoticeVisible.value = true
|
||||||
|
} else if (row.key === 'chat.renter_retention_days_after_order_end') {
|
||||||
|
generalVisible.value = true
|
||||||
} else {
|
} else {
|
||||||
generalVisible.value = true
|
generalVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -560,6 +566,39 @@ function formatConfigValue(row: SystemConfig) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="renterRetentionConfig" class="publish-config-panel">
|
||||||
|
<div class="publish-config-main">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Chat Retention</p>
|
||||||
|
<h2>发布群租客保留天数</h2>
|
||||||
|
<span>订单结束后,租客在发布群中保留的天数,超时后由后台任务自动移出。</span>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="openEdit(renterRetentionConfig)">编辑天数</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="publish-stat-grid home-stat-grid">
|
||||||
|
<div class="publish-stat">
|
||||||
|
<strong>{{ renterRetentionConfig.value }}</strong>
|
||||||
|
<span>当前天数</span>
|
||||||
|
</div>
|
||||||
|
<div class="publish-stat">
|
||||||
|
<strong>范围</strong>
|
||||||
|
<span>3-7 天</span>
|
||||||
|
</div>
|
||||||
|
<div class="publish-stat">
|
||||||
|
<strong>任务</strong>
|
||||||
|
<span>订单结束后自动清退</span>
|
||||||
|
</div>
|
||||||
|
<div class="publish-stat">
|
||||||
|
<strong>键</strong>
|
||||||
|
<span>{{ renterRetentionConfig.key }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="publish-stat">
|
||||||
|
<strong>更新</strong>
|
||||||
|
<span>{{ formatHomeConfigStatus(renterRetentionConfig, '未初始化') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="regularConfigs">
|
<el-table v-loading="loading" class="table-panel" :data="regularConfigs">
|
||||||
<el-table-column prop="key" label="配置项" min-width="260" />
|
<el-table-column prop="key" label="配置项" min-width="260" />
|
||||||
<el-table-column label="当前值" min-width="180" show-overflow-tooltip>
|
<el-table-column label="当前值" min-width="180" show-overflow-tooltip>
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ export const systemConfigSelectOptions: Record<string, SystemConfigOption[]> = {
|
|||||||
{ label: '管理员 ID 2', value: '2' },
|
{ label: '管理员 ID 2', value: '2' },
|
||||||
{ label: '管理员 ID 3', value: '3' },
|
{ 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) {
|
export function getSystemConfigSelectOptions(key: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user