package ordertimeout import ( "context" "encoding/json" "strconv" "time" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" "go.uber.org/zap" "gorm.io/datatypes" "gorm.io/gorm" "gorm.io/gorm/clause" ) type Job struct { db *gorm.DB logger *zap.Logger interval time.Duration } type thresholds struct { OwnerSubmitTimeoutMinutes int RenterConfirmTimeoutMinutes int ReturnOverdueGraceMinutes int OwnerReturnConfirmTimeoutMinutes int } func New(db *gorm.DB, logger *zap.Logger) *Job { return &Job{ db: db, logger: logger, interval: time.Minute, } } func (j *Job) Start(ctx context.Context) { if j == nil || j.db == nil { return } go j.loop(ctx) } func (j *Job) loop(ctx context.Context) { j.run(ctx) ticker := time.NewTicker(j.interval) defer ticker.Stop() for { select { case <-ctx.Done(): j.logger.Info("order timeout job stopped") return case <-ticker.C: j.run(ctx) } } } func (j *Job) run(ctx context.Context) { cfg, err := j.loadThresholds(ctx) if err != nil { j.logger.Warn("order timeout job config load failed", zap.Error(err)) return } now := time.Now() handlers := []func(context.Context, time.Time, thresholds) (int, error){ j.handleOwnerSubmitTimeout, j.handleRenterConfirmTimeout, j.handleReturnOverdue, j.handleOwnerReturnConfirmTimeout, } total := 0 for _, handler := range handlers { count, err := handler(ctx, now, cfg) if err != nil { j.logger.Warn("order timeout handler failed", zap.Error(err)) continue } total += count } if total > 0 { j.logger.Info("order timeout job processed orders", zap.Int("count", total)) } } func (j *Job) loadThresholds(ctx context.Context) (thresholds, error) { cfg := thresholds{ OwnerSubmitTimeoutMinutes: 30, RenterConfirmTimeoutMinutes: 30, ReturnOverdueGraceMinutes: 10, OwnerReturnConfirmTimeoutMinutes: 120, } var rows []model.SystemConfig err := j.db.WithContext(ctx). Where("`key` IN ?", []string{ "handoff.owner_submit_timeout_minutes", "handoff.renter_confirm_timeout_minutes", "order.return_overdue_grace_minutes", "handoff.owner_return_confirm_timeout_minutes", }). Find(&rows).Error if err != nil { return cfg, err } for _, row := range rows { value, err := strconv.Atoi(row.Value) if err != nil || value <= 0 { continue } switch row.Key { case "handoff.owner_submit_timeout_minutes": cfg.OwnerSubmitTimeoutMinutes = value case "handoff.renter_confirm_timeout_minutes": cfg.RenterConfirmTimeoutMinutes = value case "order.return_overdue_grace_minutes": cfg.ReturnOverdueGraceMinutes = value case "handoff.owner_return_confirm_timeout_minutes": cfg.OwnerReturnConfirmTimeoutMinutes = value } } return cfg, nil } func (j *Job) handleOwnerSubmitTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) { var rows []model.RentalOrder err := j.db.WithContext(ctx). Where("status = ? AND handoff_status = ? AND created_at <= ?", "pending_handoff", "pending_owner", now.Add(-time.Duration(cfg.OwnerSubmitTimeoutMinutes)*time.Minute)). Order("id ASC"). Limit(100). Find(&rows).Error if err != nil { return 0, err } count := 0 for _, row := range rows { if err := j.updateOrder(ctx, row.ID, "order.timeout.owner_submit", func(tx *gorm.DB, order *model.RentalOrder) (string, error) { if order.Status != "pending_handoff" || order.HandoffStatus != "pending_owner" { return "", nil } before := snapshot(order) order.HandoffStatus = "owner_timeout" orderID := order.ID if err := notification.Append(tx, notification.Entry{ UserID: order.RenterID, Type: "timeout", Title: "号主交接超时", Content: "号主未在规定时间内提交交接说明,你可以取消订单或发起申诉。", BizType: "order", BizID: &orderID, }, notification.Entry{ UserID: order.OwnerID, Type: "timeout", Title: "订单交接已超时", Content: "你未在规定时间内提交交接说明,租客可取消订单或发起申诉。", BizType: "order", BizID: &orderID, }, ); err != nil { return "", err } return before, nil }); err != nil { return count, err } count++ } return count, nil } func (j *Job) handleRenterConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) { var rows []model.RentalOrder err := j.db.WithContext(ctx). Where("status = ? AND handoff_status = ?", "pending_handoff", "pending_renter_confirm"). Order("id ASC"). Limit(100). Find(&rows).Error if err != nil { return 0, err } count := 0 deadline := now.Add(-time.Duration(cfg.RenterConfirmTimeoutMinutes) * time.Minute) for _, row := range rows { var handoff model.HandoffRecord err := j.db.WithContext(ctx). Where("order_id = ? AND type = ?", row.ID, "owner_handoff"). Order("id DESC"). First(&handoff).Error if err != nil || handoff.CreatedAt.After(deadline) { continue } if err := j.updateOrder(ctx, row.ID, "order.timeout.renter_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) { if order.Status != "pending_handoff" || order.HandoffStatus != "pending_renter_confirm" { return "", nil } before := snapshot(order) order.Status = "abnormal" order.HandoffStatus = "renter_confirm_timeout" orderID := order.ID if err := notification.Append(tx, notification.Entry{ UserID: order.RenterID, Type: "timeout", Title: "确认收号超时", Content: "你未在规定时间内确认收号,订单已进入客服介入状态。", BizType: "order", BizID: &orderID, }, notification.Entry{ UserID: order.OwnerID, Type: "timeout", Title: "租客确认收号超时", Content: "租客未在规定时间内确认收号,订单已进入客服介入状态。", BizType: "order", BizID: &orderID, }, ); err != nil { return "", err } return before, nil }); err != nil { return count, err } count++ } return count, nil } func (j *Job) handleReturnOverdue(ctx context.Context, now time.Time, cfg thresholds) (int, error) { var rows []model.RentalOrder err := j.db.WithContext(ctx). Where("status = ? AND rent_end_at IS NOT NULL AND rent_end_at <= ?", "renting", now.Add(-time.Duration(cfg.ReturnOverdueGraceMinutes)*time.Minute)). Order("id ASC"). Limit(100). Find(&rows).Error if err != nil { return 0, err } count := 0 for _, row := range rows { if err := j.updateOrder(ctx, row.ID, "order.timeout.return_overdue", func(tx *gorm.DB, order *model.RentalOrder) (string, error) { if order.Status != "renting" { return "", nil } before := snapshot(order) order.Status = "overdue" order.HandoffStatus = "return_overdue" orderID := order.ID if err := notification.Append(tx, notification.Entry{ UserID: order.RenterID, Type: "timeout", Title: "订单已逾期未归还", Content: "订单已超过预计截止时间,请尽快提交归还说明,避免进入申诉处理。", BizType: "order", BizID: &orderID, }, notification.Entry{ UserID: order.OwnerID, Type: "timeout", Title: "租客逾期未归还", Content: "租客未在预计截止后及时归还,你可以发起申诉或等待客服处理。", BizType: "order", BizID: &orderID, }, ); err != nil { return "", err } return before, nil }); err != nil { return count, err } count++ } return count, nil } func (j *Job) handleOwnerReturnConfirmTimeout(ctx context.Context, now time.Time, cfg thresholds) (int, error) { var rows []model.RentalOrder err := j.db.WithContext(ctx). Where("status = ? AND handoff_status = ? AND updated_at <= ?", "pending_return_confirm", "pending_owner_return_confirm", now.Add(-time.Duration(cfg.OwnerReturnConfirmTimeoutMinutes)*time.Minute)). Order("id ASC"). Limit(100). Find(&rows).Error if err != nil { return 0, err } count := 0 for _, row := range rows { if err := j.updateOrder(ctx, row.ID, "order.timeout.owner_return_confirm", func(tx *gorm.DB, order *model.RentalOrder) (string, error) { if order.Status != "pending_return_confirm" || order.HandoffStatus != "pending_owner_return_confirm" { return "", nil } before := snapshot(order) order.Status = "abnormal" order.HandoffStatus = "owner_return_confirm_timeout" orderID := order.ID if err := notification.Append(tx, notification.Entry{ UserID: order.RenterID, Type: "timeout", Title: "号主确认归还超时", Content: "号主未在规定时间内确认归还,订单已进入客服复核状态。", BizType: "order", BizID: &orderID, }, notification.Entry{ UserID: order.OwnerID, Type: "timeout", Title: "确认归还已超时", Content: "你未在规定时间内确认归还,订单已进入客服复核状态。", BizType: "order", BizID: &orderID, }, ); err != nil { return "", err } return before, nil }); err != nil { return count, err } count++ } return count, nil } 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 if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, orderID).Error; err != nil { return err } before, err := fn(tx, &order) if err != nil || before == "" { return err } if err := tx.Save(&order).Error; err != nil { return err } return appendAuditLog(tx, action, order.ID, map[string]any{ "order_id": order.ID, "order_no": order.OrderNo, "before": before, "after": snapshot(&order), }) }) } func snapshot(order *model.RentalOrder) string { raw, _ := json.Marshal(map[string]any{ "status": order.Status, "handoff_status": order.HandoffStatus, "settlement_status": order.SettlementStatus, }) return string(raw) } func appendAuditLog(tx *gorm.DB, action string, bizID uint64, detail map[string]any) error { raw, err := json.Marshal(detail) if err != nil { return err } row := model.AuditLog{ ActorType: "system", ActorID: 0, Action: action, BizType: "order", BizID: &bizID, Detail: datatypes.JSON(raw), } return tx.Create(&row).Error }