订单超时任务系统
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"hfb_sys/backend/internal/config"
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/jobs/ordertimeout"
|
||||
"hfb_sys/backend/internal/router"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -50,6 +51,11 @@ func main() {
|
||||
}
|
||||
|
||||
engine := router.New(cfg, deps, logger)
|
||||
jobCtx, stopJobs := context.WithCancel(context.Background())
|
||||
defer stopJobs()
|
||||
if deps.DB != nil {
|
||||
ordertimeout.New(deps.DB, logger).Start(jobCtx)
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: cfg.AppAddr,
|
||||
Handler: engine,
|
||||
@@ -66,6 +72,7 @@ func main() {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
stopJobs()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
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
|
||||
}
|
||||
@@ -308,7 +308,7 @@ func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitRetur
|
||||
if order.RenterID != userID {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
if order.Status != "renting" || order.HandoffStatus != "received" {
|
||||
if (order.Status != "renting" && order.Status != "overdue") || (order.HandoffStatus != "received" && order.HandoffStatus != "return_overdue") {
|
||||
return ErrOrderCannotReturn
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
Reference in New Issue
Block a user