diff --git a/README.md b/README.md index 289c4ac..f03965b 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ npm run dev - 订单创建需要登录;开发态会直接锁定账号并进入待交接,暂不接真实支付和押金冻结。 - 账号交接已支持号主提交说明、租客确认收号,确认后订单进入租赁中。 - 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。 +- 后端已接入开发态订单超时扫描任务,会按系统配置处理交接超时、确认收号超时、逾期未归还和确认归还超时。 - 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance` 和 `GET /api/wallet/ledger` 查看。 - 后台资金流水已接入,页面为 `http://localhost:5173/admin/wallet-ledger`,支持按用户、订单和业务类型查询。 - 站内信已支持订单关键节点自动写入,可通过 `GET /api/notifications` 查看。 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index adc0d23..717401b 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -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() diff --git a/backend/internal/jobs/ordertimeout/job.go b/backend/internal/jobs/ordertimeout/job.go new file mode 100644 index 0000000..8d03519 --- /dev/null +++ b/backend/internal/jobs/ordertimeout/job.go @@ -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 +} diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 9a635d0..8dc53e7 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -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() diff --git a/docs/api.md b/docs/api.md index d260099..a10e4c3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -74,3 +74,5 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `GET /api/admin/audit-logs` 说明:`GET /api/admin/listings` 支持按 `owner_id`、`status`、`review_status` 和 `limit` 查询商品。`GET /api/admin/wallet/ledger` 支持按 `user_id`、`order_id`、`biz_type` 和 `limit` 查询最近资金流水。`GET /api/admin/audit-logs` 支持按 `actor_id`、`action`、`biz_type` 和 `limit` 查询最近审计日志。`/api/admin/*` 当前已使用独立后台登录,后续接入 RBAC 和 Casbin 权限后再按角色收紧访问控制。 + +订单超时扫描任务为后端内部任务,不暴露公开 API;超时阈值通过 `/api/admin/system-configs` 调整。 diff --git a/docs/business-rules.md b/docs/business-rules.md index 16507f3..8a398d8 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -51,12 +51,24 @@ ## 开发态归还与完成 - 租赁中订单可以由租客提交归还。 +- 逾期中订单仍允许租客提交归还,但后台和号主可以根据逾期情况发起申诉或客服处理。 - 租客提交归还后,订单状态变为 `pending_return_confirm`,交接状态变为 `pending_owner_return_confirm`。 - 只有号主可以确认归还。 - 号主确认归还后,订单状态变为 `completed`,交接状态变为 `returned`,结算状态先标记为 `settled`。 - 订单完成后,账号和发布状态恢复为 `published`。 - 当前会生成开发态模拟钱包流水,不代表真实支付或提现。 +## 开发态超时任务 + +- 后端启动后会运行订单超时扫描任务,默认每 1 分钟执行一次。 +- 任务从 `system_configs` 读取超时阈值,包括号主待交接、租客确认收号、租客归还宽限和号主确认归还。 +- 号主待交接超时:订单保持 `pending_handoff`,交接状态变为 `owner_timeout`,租客仍可取消订单或发起申诉。 +- 租客确认收号超时:订单状态变为 `abnormal`,交接状态变为 `renter_confirm_timeout`,进入客服介入。 +- 租客逾期未归还:订单状态变为 `overdue`,交接状态变为 `return_overdue`,租客仍可提交归还,号主可发起申诉。 +- 号主确认归还超时:订单状态变为 `abnormal`,交接状态变为 `owner_return_confirm_timeout`,进入客服复核。 +- 每个超时动作只推进一次状态,避免重复通知。 +- 超时动作会给相关用户写入站内信,并以 `system` 身份写入 `audit_logs`。 + ## 开发态钱包账务 - 钱包账务当前为模拟流水,不代表真实支付、充值或提现。 diff --git a/frontend/src/views/account/OrderDetailView.vue b/frontend/src/views/account/OrderDetailView.vue index 724076e..43731ec 100644 --- a/frontend/src/views/account/OrderDetailView.vue +++ b/frontend/src/views/account/OrderDetailView.vue @@ -220,7 +220,7 @@ function readError(error: unknown, fallback: string) { 确认已收到账号 -
+

提交归还

提交归还 diff --git a/frontend/src/views/admin/AdminAuditLogsView.vue b/frontend/src/views/admin/AdminAuditLogsView.vue index d6c9058..62edd2e 100644 --- a/frontend/src/views/admin/AdminAuditLogsView.vue +++ b/frontend/src/views/admin/AdminAuditLogsView.vue @@ -93,6 +93,10 @@ function actionType(action: string) { + + + + diff --git a/frontend/src/views/admin/AdminOrdersView.vue b/frontend/src/views/admin/AdminOrdersView.vue index 8d898da..73dec19 100644 --- a/frontend/src/views/admin/AdminOrdersView.vue +++ b/frontend/src/views/admin/AdminOrdersView.vue @@ -36,6 +36,7 @@ async function loadOrders() { +