diff --git a/README.md b/README.md index df5e507..3c99605 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ npm run dev - 租号发布需要登录并完成实名认证;开发态 `POST /api/listings/{id}/submit-review` 会自动审核通过并上架。 - 订单创建需要登录;开发态会直接锁定账号并进入待交接,暂不接真实支付和押金冻结。 - 账号交接已支持号主提交说明、租客确认收号,确认后订单进入租赁中。 +- 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。 +- 钱包账务当前为开发态模拟流水,可通过 `GET /api/wallet/balance` 和 `GET /api/wallet/ledger` 查看。 ## 文档 diff --git a/backend/internal/model/wallet.go b/backend/internal/model/wallet.go new file mode 100644 index 0000000..a1ed59e --- /dev/null +++ b/backend/internal/model/wallet.go @@ -0,0 +1,36 @@ +package model + +import "time" + +type WalletAccount struct { + ID uint64 `gorm:"primaryKey" json:"id"` + UserID uint64 `gorm:"not null;uniqueIndex" json:"user_id"` + AvailableBalance float64 `gorm:"type:decimal(12,2);not null;default:0" json:"available_balance"` + FrozenBalance float64 `gorm:"type:decimal(12,2);not null;default:0" json:"frozen_balance"` + Status string `gorm:"size:32;not null;default:'active'" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (WalletAccount) TableName() string { + return "wallet_accounts" +} + +type WalletLedger struct { + ID uint64 `gorm:"primaryKey" json:"id"` + LedgerNo string `gorm:"size:64;not null;uniqueIndex" json:"ledger_no"` + UserID uint64 `gorm:"not null;index" json:"user_id"` + OrderID *uint64 `json:"order_id"` + Direction string `gorm:"size:16;not null" json:"direction"` + Amount float64 `gorm:"type:decimal(12,2);not null" json:"amount"` + BalanceAfter float64 `gorm:"type:decimal(12,2);not null" json:"balance_after"` + BalanceType string `gorm:"size:32;not null" json:"balance_type"` + BizType string `gorm:"size:32;not null" json:"biz_type"` + BizNo string `gorm:"size:64;not null" json:"biz_no"` + Remark string `gorm:"size:255;not null;default:''" json:"remark"` + CreatedAt time.Time `json:"created_at"` +} + +func (WalletLedger) TableName() string { + return "wallet_ledger" +} diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 8859963..1baff09 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -39,6 +39,10 @@ type SubmitHandoffRequest struct { Content string `json:"content" binding:"required"` } +type SubmitReturnRequest struct { + Content string `json:"content" binding:"required"` +} + type HandoffRecordDTO struct { ID uint64 `json:"id"` OrderID uint64 `json:"order_id"` diff --git a/backend/internal/modules/order/handler.go b/backend/internal/modules/order/handler.go index 081b097..f87ed53 100644 --- a/backend/internal/modules/order/handler.go +++ b/backend/internal/modules/order/handler.go @@ -145,6 +145,46 @@ func (h *Handler) HandoffRecords(c *gin.Context) { response.OK(c, gin.H{"items": items}) } +func (h *Handler) SubmitReturn(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req SubmitReturnRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "归还说明不能为空") + return + } + record, err := h.service.SubmitReturn(userID, id, req) + if err != nil { + writeOrderError(c, err) + return + } + response.Created(c, record) +} + +func (h *Handler) ConfirmReturn(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + if err := h.service.ConfirmReturn(userID, id); err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"completed": true}) +} + func currentUserID(c *gin.Context) (uint64, bool) { value, ok := c.Get(middleware.ContextUserID) if !ok { @@ -179,6 +219,10 @@ func writeOrderError(c *gin.Context, err error) { response.Error(c, http.StatusConflict, "order_cannot_handoff", "当前订单不能交接") case errors.Is(err, ErrOrderCannotReceive): response.Error(c, http.StatusConflict, "order_cannot_receive", "当前订单不能确认收号") + case errors.Is(err, ErrOrderCannotReturn): + response.Error(c, http.StatusConflict, "order_cannot_return", "当前订单不能归还") + case errors.Is(err, ErrOrderCannotComplete): + response.Error(c, http.StatusConflict, "order_cannot_complete", "当前订单不能完成") case errors.Is(err, ErrPermissionDenied): response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单") case IsNotFound(err): diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go index 5a059e3..a9915c8 100644 --- a/backend/internal/modules/order/repository.go +++ b/backend/internal/modules/order/repository.go @@ -5,10 +5,11 @@ import ( "encoding/hex" "encoding/json" "errors" - "fmt" + "strconv" "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/wallet" "gorm.io/datatypes" "gorm.io/gorm" @@ -74,6 +75,21 @@ func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, erro if err := tx.Create(&order).Error; err != nil { return err } + orderID := order.ID + if err := wallet.AppendEntries(tx, + wallet.Entry{ + UserID: renterID, + OrderID: &orderID, + Direction: "in", + Amount: order.RentAmount + order.DepositAmount, + BalanceType: "frozen", + BizType: "order_lock", + BizNo: order.OrderNo, + Remark: "开发态模拟冻结租金和押金", + }, + ); err != nil { + return err + } listing.Status = "rented" account.Status = "rented" if err := tx.Save(&listing).Error; err != nil { @@ -113,6 +129,21 @@ func (r *Repository) Cancel(userID uint64, orderID uint64) error { order.Status = "cancelled" order.HandoffStatus = "cancelled" + orderID := order.ID + if err := wallet.AppendEntries(tx, + wallet.Entry{ + UserID: order.RenterID, + OrderID: &orderID, + Direction: "out", + Amount: order.RentAmount + order.DepositAmount, + BalanceType: "frozen", + BizType: "order_cancel", + BizNo: order.OrderNo, + Remark: "取消订单释放模拟冻结金额", + }, + ); err != nil { + return err + } listing.Status = "published" account.Status = "published" if err := tx.Save(&order).Error; err != nil { @@ -204,6 +235,123 @@ func (r *Repository) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRec return items, nil } +func (r *Repository) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) { + var recordID uint64 + err := r.db.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 + } + if order.RenterID != userID { + return ErrPermissionDenied + } + if order.Status != "renting" || order.HandoffStatus != "received" { + return ErrOrderCannotReturn + } + now := time.Now() + record := model.HandoffRecord{ + OrderID: order.ID, + FromUserID: order.RenterID, + ToUserID: order.OwnerID, + Type: "renter_return", + Content: req.Content, + } + if err := tx.Create(&record).Error; err != nil { + return err + } + order.Status = "pending_return_confirm" + order.HandoffStatus = "pending_owner_return_confirm" + order.RentEndAt = &now + if err := tx.Save(&order).Error; err != nil { + return err + } + recordID = record.ID + return nil + }) + if err != nil { + return nil, err + } + return r.findHandoffRecord(recordID) +} + +func (r *Repository) ConfirmReturn(userID uint64, orderID uint64) error { + return r.db.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 + } + if order.OwnerID != userID { + return ErrPermissionDenied + } + if order.Status != "pending_return_confirm" || order.HandoffStatus != "pending_owner_return_confirm" { + return ErrOrderCannotComplete + } + now := time.Now() + if err := tx.Model(&model.HandoffRecord{}). + Where("order_id = ? AND type = ?", order.ID, "renter_return"). + Update("confirmed_by_owner_at", now).Error; err != nil { + return err + } + var listing model.RentalListing + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, order.ListingID).Error; err != nil { + return err + } + var account model.GameAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, order.AccountID).Error; err != nil { + return err + } + order.Status = "completed" + order.HandoffStatus = "returned" + order.SettlementStatus = "settled" + order.SettledAt = &now + order.OwnerSettledAt = &now + orderID := order.ID + if err := wallet.AppendEntries(tx, + wallet.Entry{ + UserID: order.RenterID, + OrderID: &orderID, + Direction: "out", + Amount: order.RentAmount + order.DepositAmount, + BalanceType: "frozen", + BizType: "order_settle", + BizNo: order.OrderNo, + Remark: "订单完成释放模拟冻结金额", + }, + wallet.Entry{ + UserID: order.OwnerID, + OrderID: &orderID, + Direction: "in", + Amount: order.RentAmount, + BalanceType: "available", + BizType: "owner_income", + BizNo: order.OrderNo, + Remark: "订单完成模拟结算租金", + }, + wallet.Entry{ + UserID: order.RenterID, + OrderID: &orderID, + Direction: "in", + Amount: order.DepositAmount, + BalanceType: "available", + BizType: "deposit_release", + BizNo: order.OrderNo, + Remark: "订单完成模拟退回押金", + }, + ); err != nil { + return err + } + listing.Status = "published" + account.Status = "published" + if err := tx.Save(&order).Error; err != nil { + return err + } + if err := tx.Save(&listing).Error; err != nil { + return err + } + return tx.Save(&account).Error + }) +} + func (r *Repository) ListForUser(userID uint64) ([]OrderDTO, error) { var rows []orderRow err := r.baseQuery(). @@ -316,7 +464,7 @@ func newOrderNo() (string, error) { if _, err := rand.Read(buf); err != nil { return "", err } - return fmt.Sprintf("RO%d%s", time.Now().UnixNano(), hex.EncodeToString(buf)), nil + return "RO" + strconv.FormatInt(time.Now().UnixNano(), 10) + hex.EncodeToString(buf), nil } func IsNotFound(err error) bool { diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index 954fea2..1d640f7 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -10,6 +10,8 @@ var ( ErrOrderCannotCancel = errors.New("order cannot cancel") ErrOrderCannotHandoff = errors.New("order cannot handoff") ErrOrderCannotReceive = errors.New("order cannot receive") + ErrOrderCannotReturn = errors.New("order cannot return") + ErrOrderCannotComplete = errors.New("order cannot complete") ErrPermissionDenied = errors.New("permission denied") ) @@ -62,6 +64,23 @@ func (s *Service) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRecord return s.repo.HandoffRecords(userID, orderID) } +func (s *Service) SubmitReturn(userID uint64, orderID uint64, req SubmitReturnRequest) (*HandoffRecordDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if orderID == 0 || req.Content == "" { + return nil, ErrOrderCannotReturn + } + return s.repo.SubmitReturn(userID, orderID, req) +} + +func (s *Service) ConfirmReturn(userID uint64, orderID uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.ConfirmReturn(userID, orderID) +} + func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/modules/wallet/dto.go b/backend/internal/modules/wallet/dto.go new file mode 100644 index 0000000..3312941 --- /dev/null +++ b/backend/internal/modules/wallet/dto.go @@ -0,0 +1,25 @@ +package wallet + +import "time" + +type AccountDTO struct { + UserID uint64 `json:"user_id"` + AvailableBalance float64 `json:"available_balance"` + FrozenBalance float64 `json:"frozen_balance"` + Status string `json:"status"` +} + +type LedgerDTO struct { + ID uint64 `json:"id"` + LedgerNo string `json:"ledger_no"` + UserID uint64 `json:"user_id"` + OrderID *uint64 `json:"order_id"` + Direction string `json:"direction"` + Amount float64 `json:"amount"` + BalanceAfter float64 `json:"balance_after"` + BalanceType string `json:"balance_type"` + BizType string `json:"biz_type"` + BizNo string `json:"biz_no"` + Remark string `json:"remark"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/modules/wallet/handler.go b/backend/internal/modules/wallet/handler.go new file mode 100644 index 0000000..efcdc7a --- /dev/null +++ b/backend/internal/modules/wallet/handler.go @@ -0,0 +1,64 @@ +package wallet + +import ( + "errors" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) Balance(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + account, err := h.service.Account(userID) + if err != nil { + writeWalletError(c, err) + return + } + response.OK(c, account) +} + +func (h *Handler) Ledger(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + items, err := h.service.Ledger(userID) + if err != nil { + writeWalletError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func currentUserID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextUserID) + if !ok { + return 0, false + } + userID, ok := value.(uint64) + return userID, ok +} + +func writeWalletError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + default: + response.ServiceUnavailable(c, "钱包服务暂时不可用") + } +} diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go new file mode 100644 index 0000000..5216946 --- /dev/null +++ b/backend/internal/modules/wallet/repository.go @@ -0,0 +1,165 @@ +package wallet + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB +} + +type Entry struct { + UserID uint64 + OrderID *uint64 + Direction string + Amount float64 + BalanceType string + BizType string + BizNo string + Remark string +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Account(userID uint64) (*AccountDTO, error) { + var account model.WalletAccount + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := ensureAccount(tx, userID); err != nil { + return err + } + return tx.Where("user_id = ?", userID).First(&account).Error + }) + if err != nil { + return nil, err + } + return toAccountDTO(account), nil +} + +func (r *Repository) Ledger(userID uint64) ([]LedgerDTO, error) { + var rows []model.WalletLedger + if err := r.db.Where("user_id = ?", userID).Order("id DESC").Limit(100).Find(&rows).Error; err != nil { + return nil, err + } + items := make([]LedgerDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, toLedgerDTO(row)) + } + return items, nil +} + +func AppendEntries(tx *gorm.DB, entries ...Entry) error { + for _, entry := range entries { + if entry.Amount <= 0 { + continue + } + if err := ensureAccount(tx, entry.UserID); err != nil { + return err + } + var account model.WalletAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", entry.UserID).First(&account).Error; err != nil { + return err + } + balanceAfter, err := applyEntry(&account, entry) + if err != nil { + return err + } + if err := tx.Save(&account).Error; err != nil { + return err + } + ledgerNo, err := newLedgerNo() + if err != nil { + return err + } + ledger := model.WalletLedger{ + LedgerNo: ledgerNo, + UserID: entry.UserID, + OrderID: entry.OrderID, + Direction: entry.Direction, + Amount: entry.Amount, + BalanceAfter: balanceAfter, + BalanceType: entry.BalanceType, + BizType: entry.BizType, + BizNo: entry.BizNo, + Remark: entry.Remark, + } + if err := tx.Create(&ledger).Error; err != nil { + return err + } + } + return nil +} + +func ensureAccount(tx *gorm.DB, userID uint64) error { + account := model.WalletAccount{ + UserID: userID, + AvailableBalance: 0, + FrozenBalance: 0, + Status: "active", + } + return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error +} + +func applyEntry(account *model.WalletAccount, entry Entry) (float64, error) { + switch entry.BalanceType { + case "available": + if entry.Direction == "in" { + account.AvailableBalance += entry.Amount + } else { + account.AvailableBalance -= entry.Amount + } + return account.AvailableBalance, nil + case "frozen": + if entry.Direction == "in" { + account.FrozenBalance += entry.Amount + } else { + account.FrozenBalance -= entry.Amount + } + return account.FrozenBalance, nil + default: + return 0, fmt.Errorf("unsupported balance type: %s", entry.BalanceType) + } +} + +func toAccountDTO(account model.WalletAccount) *AccountDTO { + return &AccountDTO{ + UserID: account.UserID, + AvailableBalance: account.AvailableBalance, + FrozenBalance: account.FrozenBalance, + Status: account.Status, + } +} + +func toLedgerDTO(row model.WalletLedger) LedgerDTO { + return LedgerDTO{ + ID: row.ID, + LedgerNo: row.LedgerNo, + UserID: row.UserID, + OrderID: row.OrderID, + Direction: row.Direction, + Amount: row.Amount, + BalanceAfter: row.BalanceAfter, + BalanceType: row.BalanceType, + BizType: row.BizType, + BizNo: row.BizNo, + Remark: row.Remark, + CreatedAt: row.CreatedAt, + } +} + +func newLedgerNo() (string, error) { + buf := make([]byte, 4) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return fmt.Sprintf("WL%d%s", time.Now().UnixNano(), hex.EncodeToString(buf)), nil +} diff --git a/backend/internal/modules/wallet/service.go b/backend/internal/modules/wallet/service.go new file mode 100644 index 0000000..897aa9d --- /dev/null +++ b/backend/internal/modules/wallet/service.go @@ -0,0 +1,27 @@ +package wallet + +import "errors" + +var ErrDependencyUnavailable = errors.New("dependency unavailable") + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Account(userID uint64) (*AccountDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Account(userID) +} + +func (s *Service) Ledger(userID uint64) ([]LedgerDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Ledger(userID) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 7d85b5f..20fcd9a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -9,6 +9,7 @@ import ( "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/realname" "hfb_sys/backend/internal/modules/user" + "hfb_sys/backend/internal/modules/wallet" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -52,6 +53,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } orderService := order.NewService(orderRepo) orderHandler := order.NewHandler(orderService) + var walletRepo *wallet.Repository + if deps.DB != nil { + walletRepo = wallet.NewRepository(deps.DB) + } + walletService := wallet.NewService(walletRepo) + walletHandler := wallet.NewHandler(walletService) requireAuth := middleware.Auth(jwtManager) requireRealname := middleware.RequireRealname(userRepo) @@ -94,6 +101,14 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff) orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords) orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive) + orderRoutes.POST("/:id/return", orderHandler.SubmitReturn) + orderRoutes.POST("/:id/confirm-return", orderHandler.ConfirmReturn) + } + + walletRoutes := api.Group("/wallet", requireAuth) + { + walletRoutes.GET("/balance", walletHandler.Balance) + walletRoutes.GET("/ledger", walletHandler.Ledger) } realnameRoutes := api.Group("/realname", requireAuth) diff --git a/docs/api.md b/docs/api.md index be46637..ba7580a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -37,3 +37,7 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `POST /api/orders/{id}/handoff` - `GET /api/orders/{id}/handoff-records` - `POST /api/orders/{id}/confirm-receive` +- `POST /api/orders/{id}/return` +- `POST /api/orders/{id}/confirm-return` +- `GET /api/wallet/balance` +- `GET /api/wallet/ledger` diff --git a/docs/business-rules.md b/docs/business-rules.md index 85feb33..158bc96 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -45,3 +45,21 @@ - 租客确认收号后,订单状态变为 `renting`,交接状态变为 `received`。 - 确认收号时会重新计算租赁开始时间和结束时间。 - 交接记录保存在 `handoff_records`,订单双方都可以查看。 + +## 开发态归还与完成 + +- 租赁中订单可以由租客提交归还。 +- 租客提交归还后,订单状态变为 `pending_return_confirm`,交接状态变为 `pending_owner_return_confirm`。 +- 只有号主可以确认归还。 +- 号主确认归还后,订单状态变为 `completed`,交接状态变为 `returned`,结算状态先标记为 `settled`。 +- 订单完成后,账号和发布状态恢复为 `published`。 +- 当前只做状态闭环,不生成真实钱包流水,资金流水后续接入。 + +## 开发态钱包账务 + +- 钱包账务当前为模拟流水,不代表真实支付、充值或提现。 +- 创建订单时为租客生成一笔冻结流水,金额为租金加押金。 +- 取消待交接订单时释放租客冻结金额。 +- 订单完成时释放租客冻结金额,给号主生成租金入账流水,并给租客生成押金退回流水。 +- 每笔流水记录 `balance_after`,用于后续对账。 +- 后续接入真实支付后,需要把模拟冻结替换为支付成功后的真实冻结。 diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index cc30549..39fd1d1 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -79,3 +79,13 @@ export async function confirmReceive(id: number) { const { data } = await apiClient.post>(`/orders/${id}/confirm-receive`) return data.data } + +export async function submitReturn(id: number, content: string) { + const { data } = await apiClient.post>(`/orders/${id}/return`, { content }) + return data.data +} + +export async function confirmReturn(id: number) { + const { data } = await apiClient.post>(`/orders/${id}/confirm-return`) + return data.data +} diff --git a/frontend/src/api/wallet.ts b/frontend/src/api/wallet.ts new file mode 100644 index 0000000..7ada290 --- /dev/null +++ b/frontend/src/api/wallet.ts @@ -0,0 +1,39 @@ +import { apiClient } from './client' + +export interface WalletAccount { + user_id: number + available_balance: number + frozen_balance: number + status: string +} + +export interface WalletLedger { + id: number + ledger_no: string + user_id: number + order_id?: number + direction: string + amount: number + balance_after: number + balance_type: string + biz_type: string + biz_no: string + remark: string + created_at: string +} + +interface ApiResponse { + code: string + message: string + data: T +} + +export async function fetchWalletBalance() { + const { data } = await apiClient.get>('/wallet/balance') + return data.data +} + +export async function fetchWalletLedger() { + const { data } = await apiClient.get>('/wallet/ledger') + return data.data.items +} diff --git a/frontend/src/views/account/OrderDetailView.vue b/frontend/src/views/account/OrderDetailView.vue index b2fd09e..c7e23d7 100644 --- a/frontend/src/views/account/OrderDetailView.vue +++ b/frontend/src/views/account/OrderDetailView.vue @@ -6,9 +6,11 @@ import { useRoute, useRouter } from 'vue-router' import { cancelOrder, confirmReceive, + confirmReturn, fetchHandoffRecords, fetchOrder, submitHandoff, + submitReturn, type HandoffRecord, type Order, } from '@/api/orders' @@ -21,9 +23,12 @@ const loading = ref(false) const cancelling = ref(false) const handoffing = ref(false) const confirming = ref(false) +const returning = ref(false) +const completing = ref(false) const order = ref(null) const handoffRecords = ref([]) const handoffContent = ref('') +const returnContent = ref('') const isOwner = computed(() => order.value?.owner_id === session.userId) const isRenter = computed(() => order.value?.renter_id === session.userId) @@ -83,6 +88,35 @@ async function handleConfirmReceive() { } } +async function handleSubmitReturn() { + if (!order.value) return + returning.value = true + try { + await submitReturn(order.value.id, returnContent.value) + returnContent.value = '' + ElMessage.success('归还申请已提交,等待号主确认') + await loadOrder() + } catch (error) { + ElMessage.error(readError(error, '提交归还失败')) + } finally { + returning.value = false + } +} + +async function handleConfirmReturn() { + if (!order.value) return + completing.value = true + try { + await confirmReturn(order.value.id) + ElMessage.success('归还已确认,订单完成') + await loadOrder() + } catch (error) { + ElMessage.error(readError(error, '确认归还失败')) + } finally { + completing.value = false + } +} + function readError(error: unknown, fallback: string) { if (typeof error === 'object' && error && 'response' in error) { const response = (error as { response?: { data?: { message?: string } } }).response @@ -152,5 +186,17 @@ function readError(error: unknown, fallback: string) {

确认账号可以正常登录后,订单会进入租赁中并重新计算租期结束时间。

确认已收到账号 + +
+

提交归还

+ + 提交归还 +
+ +
+

确认归还

+

确认账号状态无误后,订单会完成,账号重新上架。

+ 确认归还并完成订单 +
diff --git a/frontend/src/views/account/WalletView.vue b/frontend/src/views/account/WalletView.vue index dddc3e5..7d125ca 100644 --- a/frontend/src/views/account/WalletView.vue +++ b/frontend/src/views/account/WalletView.vue @@ -1,9 +1,58 @@ + +