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) {