diff --git a/README.md b/README.md index de91258..df5e507 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ npm run dev - 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。 - 实名状态可通过 `GET /api/realname/status` 查询。 - 租号发布需要登录并完成实名认证;开发态 `POST /api/listings/{id}/submit-review` 会自动审核通过并上架。 +- 订单创建需要登录;开发态会直接锁定账号并进入待交接,暂不接真实支付和押金冻结。 +- 账号交接已支持号主提交说明、租客确认收号,确认后订单进入租赁中。 ## 文档 diff --git a/backend/internal/model/handoff.go b/backend/internal/model/handoff.go new file mode 100644 index 0000000..f393a30 --- /dev/null +++ b/backend/internal/model/handoff.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +type HandoffRecord struct { + ID uint64 `gorm:"primaryKey" json:"id"` + OrderID uint64 `gorm:"not null;index" json:"order_id"` + FromUserID uint64 `gorm:"not null" json:"from_user_id"` + ToUserID uint64 `gorm:"not null" json:"to_user_id"` + Type string `gorm:"size:32;not null" json:"type"` + Content string `json:"content"` + AttachmentURLS datatypes.JSON `gorm:"column:attachment_urls" json:"attachment_urls"` + ConfirmedByRenterAt *time.Time `json:"confirmed_by_renter_at"` + ConfirmedByOwnerAt *time.Time `json:"confirmed_by_owner_at"` + CreatedAt time.Time `json:"created_at"` +} + +func (HandoffRecord) TableName() string { + return "handoff_records" +} diff --git a/backend/internal/model/order.go b/backend/internal/model/order.go new file mode 100644 index 0000000..0f900b2 --- /dev/null +++ b/backend/internal/model/order.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +type RentalOrder struct { + ID uint64 `gorm:"primaryKey" json:"id"` + OrderNo string `gorm:"size:64;not null;uniqueIndex" json:"order_no"` + ListingID uint64 `gorm:"not null;index" json:"listing_id"` + AccountID uint64 `gorm:"not null;index" json:"account_id"` + OwnerID uint64 `gorm:"not null;index" json:"owner_id"` + RenterID uint64 `gorm:"not null;index" json:"renter_id"` + RentStartAt *time.Time `json:"rent_start_at"` + RentEndAt *time.Time `json:"rent_end_at"` + RentHours int `gorm:"not null" json:"rent_hours"` + RentAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"rent_amount"` + DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"` + PlatformFee float64 `gorm:"type:decimal(12,2);not null;default:0" json:"platform_fee"` + AccountSnapshot datatypes.JSON `json:"account_snapshot"` + Status string `gorm:"size:32;not null;default:'pending_confirm'" json:"status"` + HandoffStatus string `gorm:"size:32;not null;default:'none'" json:"handoff_status"` + SettlementStatus string `gorm:"size:32;not null;default:'unsettled'" json:"settlement_status"` + OwnerSettledAt *time.Time `json:"owner_settled_at"` + SettledAt *time.Time `json:"settled_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (RentalOrder) TableName() string { + return "rental_orders" +} diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go new file mode 100644 index 0000000..8859963 --- /dev/null +++ b/backend/internal/modules/order/dto.go @@ -0,0 +1,52 @@ +package order + +import ( + "time" + + "gorm.io/datatypes" +) + +type OrderDTO struct { + ID uint64 `json:"id"` + OrderNo string `json:"order_no"` + ListingID uint64 `json:"listing_id"` + AccountID uint64 `json:"account_id"` + OwnerID uint64 `json:"owner_id"` + RenterID uint64 `json:"renter_id"` + Title string `json:"title"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + RentStartAt *time.Time `json:"rent_start_at"` + RentEndAt *time.Time `json:"rent_end_at"` + RentHours int `json:"rent_hours"` + RentAmount float64 `json:"rent_amount"` + DepositAmount float64 `json:"deposit_amount"` + PlatformFee float64 `json:"platform_fee"` + AccountSnapshot datatypes.JSON `json:"account_snapshot"` + Status string `json:"status"` + HandoffStatus string `json:"handoff_status"` + SettlementStatus string `json:"settlement_status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreateRequest struct { + ListingID uint64 `json:"listing_id" binding:"required"` + RentHours int `json:"rent_hours" binding:"required"` +} + +type SubmitHandoffRequest struct { + Content string `json:"content" binding:"required"` +} + +type HandoffRecordDTO struct { + ID uint64 `json:"id"` + OrderID uint64 `json:"order_id"` + FromUserID uint64 `json:"from_user_id"` + ToUserID uint64 `json:"to_user_id"` + Type string `json:"type"` + Content string `json:"content"` + ConfirmedByRenterAt *time.Time `json:"confirmed_by_renter_at"` + ConfirmedByOwnerAt *time.Time `json:"confirmed_by_owner_at"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/internal/modules/order/handler.go b/backend/internal/modules/order/handler.go new file mode 100644 index 0000000..081b097 --- /dev/null +++ b/backend/internal/modules/order/handler.go @@ -0,0 +1,189 @@ +package order + +import ( + "errors" + "net/http" + "strconv" + + "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) Create(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + var req CreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "订单信息不完整") + return + } + item, err := h.service.Create(userID, req) + if err != nil { + writeOrderError(c, err) + return + } + response.Created(c, item) +} + +func (h *Handler) List(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + items, err := h.service.ListForUser(userID) + if err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *Handler) Detail(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.FindForUser(userID, id) + if err != nil { + writeOrderError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Cancel(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + if err := h.service.Cancel(userID, id); err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"cancelled": true}) +} + +func (h *Handler) SubmitHandoff(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req SubmitHandoffRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "交接说明不能为空") + return + } + record, err := h.service.SubmitHandoff(userID, id, req) + if err != nil { + writeOrderError(c, err) + return + } + response.Created(c, record) +} + +func (h *Handler) ConfirmReceive(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + if err := h.service.ConfirmReceive(userID, id); err != nil { + writeOrderError(c, err) + return + } + response.OK(c, gin.H{"received": true}) +} + +func (h *Handler) HandoffRecords(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + items, err := h.service.HandoffRecords(userID, id) + if err != nil { + writeOrderError(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 parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 不正确") + return 0, false + } + return id, true +} + +func writeOrderError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrInvalidRentHours): + response.BadRequest(c, "租期不符合规则") + case errors.Is(err, ErrListingUnavailable): + response.Error(c, http.StatusConflict, "listing_unavailable", "该账号暂不可租") + case errors.Is(err, ErrCannotRentOwnListing): + response.BadRequest(c, "不能租用自己发布的账号") + case errors.Is(err, ErrOrderCannotCancel): + response.Error(c, http.StatusConflict, "order_cannot_cancel", "当前订单不能取消") + case errors.Is(err, ErrOrderCannotHandoff): + 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, ErrPermissionDenied): + response.Error(c, http.StatusForbidden, "permission_denied", "无权操作该订单") + case IsNotFound(err): + response.Error(c, http.StatusNotFound, "not_found", "订单不存在") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "订单服务暂时不可用") + } +} diff --git a/backend/internal/modules/order/repository.go b/backend/internal/modules/order/repository.go new file mode 100644 index 0000000..5a059e3 --- /dev/null +++ b/backend/internal/modules/order/repository.go @@ -0,0 +1,324 @@ +package order + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Create(renterID uint64, req CreateRequest) (*OrderDTO, error) { + var createdID uint64 + err := r.db.Transaction(func(tx *gorm.DB) error { + var listing model.RentalListing + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, req.ListingID).Error; err != nil { + return err + } + if listing.Status != "published" || listing.ReviewStatus != "approved" { + return ErrListingUnavailable + } + if listing.OwnerID == renterID { + return ErrCannotRentOwnListing + } + if req.RentHours < listing.MinRentHours || req.RentHours > listing.MaxRentHours { + return ErrInvalidRentHours + } + + var account model.GameAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, listing.AccountID).Error; err != nil { + return err + } + snapshot, err := makeAccountSnapshot(account) + if err != nil { + return err + } + orderNo, err := newOrderNo() + if err != nil { + return err + } + now := time.Now() + rentEnd := now.Add(time.Duration(req.RentHours) * time.Hour) + order := model.RentalOrder{ + OrderNo: orderNo, + ListingID: listing.ID, + AccountID: listing.AccountID, + OwnerID: listing.OwnerID, + RenterID: renterID, + RentStartAt: &now, + RentEndAt: &rentEnd, + RentHours: req.RentHours, + RentAmount: listing.PriceHourly * float64(req.RentHours), + DepositAmount: listing.DepositAmount, + PlatformFee: 0, + AccountSnapshot: snapshot, + Status: "pending_handoff", + HandoffStatus: "pending_owner", + SettlementStatus: "unsettled", + } + if err := tx.Create(&order).Error; err != nil { + return err + } + listing.Status = "rented" + account.Status = "rented" + if err := tx.Save(&listing).Error; err != nil { + return err + } + if err := tx.Save(&account).Error; err != nil { + return err + } + createdID = order.ID + return nil + }) + if err != nil { + return nil, err + } + return r.FindForUser(renterID, createdID) +} + +func (r *Repository) Cancel(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"}). + Where("id = ? AND renter_id = ?", orderID, userID). + First(&order).Error; err != nil { + return err + } + if order.Status != "pending_handoff" { + return ErrOrderCannotCancel + } + 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 = "cancelled" + order.HandoffStatus = "cancelled" + 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) SubmitHandoff(userID uint64, orderID uint64, req SubmitHandoffRequest) (*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.OwnerID != userID { + return ErrPermissionDenied + } + if order.Status != "pending_handoff" || order.HandoffStatus != "pending_owner" { + return ErrOrderCannotHandoff + } + record := model.HandoffRecord{ + OrderID: order.ID, + FromUserID: order.OwnerID, + ToUserID: order.RenterID, + Type: "owner_handoff", + Content: req.Content, + } + if err := tx.Create(&record).Error; err != nil { + return err + } + order.HandoffStatus = "pending_renter_confirm" + 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) ConfirmReceive(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.RenterID != userID { + return ErrPermissionDenied + } + if order.Status != "pending_handoff" || order.HandoffStatus != "pending_renter_confirm" { + return ErrOrderCannotReceive + } + now := time.Now() + if err := tx.Model(&model.HandoffRecord{}). + Where("order_id = ? AND type = ?", order.ID, "owner_handoff"). + Update("confirmed_by_renter_at", now).Error; err != nil { + return err + } + order.Status = "renting" + order.HandoffStatus = "received" + order.RentStartAt = &now + rentEnd := now.Add(time.Duration(order.RentHours) * time.Hour) + order.RentEndAt = &rentEnd + return tx.Save(&order).Error + }) +} + +func (r *Repository) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRecordDTO, error) { + var order model.RentalOrder + if err := r.db.Where("id = ? AND (renter_id = ? OR owner_id = ?)", orderID, userID, userID).First(&order).Error; err != nil { + return nil, err + } + var records []model.HandoffRecord + if err := r.db.Where("order_id = ?", orderID).Order("id ASC").Find(&records).Error; err != nil { + return nil, err + } + items := make([]HandoffRecordDTO, 0, len(records)) + for _, record := range records { + items = append(items, toHandoffDTO(record)) + } + return items, nil +} + +func (r *Repository) ListForUser(userID uint64) ([]OrderDTO, error) { + var rows []orderRow + err := r.baseQuery(). + Where("o.renter_id = ?", userID). + Order("o.id DESC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + items := make([]OrderDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return items, nil +} + +func (r *Repository) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) { + var row orderRow + if err := r.baseQuery(). + Where("o.id = ? AND (o.renter_id = ? OR o.owner_id = ?)", orderID, userID, userID). + First(&row).Error; err != nil { + return nil, err + } + dto := row.toDTO() + return &dto, nil +} + +func (r *Repository) findHandoffRecord(id uint64) (*HandoffRecordDTO, error) { + var record model.HandoffRecord + if err := r.db.First(&record, id).Error; err != nil { + return nil, err + } + dto := toHandoffDTO(record) + return &dto, nil +} + +func (r *Repository) baseQuery() *gorm.DB { + return r.db.Table("rental_orders AS o"). + Select("o.*, a.title, a.server_region, a.login_platform"). + Joins("JOIN game_accounts AS a ON a.id = o.account_id") +} + +type orderRow struct { + model.RentalOrder + Title string + ServerRegion string + LoginPlatform string +} + +func (row orderRow) toDTO() OrderDTO { + return OrderDTO{ + ID: row.ID, + OrderNo: row.OrderNo, + ListingID: row.ListingID, + AccountID: row.AccountID, + OwnerID: row.OwnerID, + RenterID: row.RenterID, + Title: row.Title, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + RentStartAt: row.RentStartAt, + RentEndAt: row.RentEndAt, + RentHours: row.RentHours, + RentAmount: row.RentAmount, + DepositAmount: row.DepositAmount, + PlatformFee: row.PlatformFee, + AccountSnapshot: row.AccountSnapshot, + Status: row.Status, + HandoffStatus: row.HandoffStatus, + SettlementStatus: row.SettlementStatus, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func toHandoffDTO(record model.HandoffRecord) HandoffRecordDTO { + return HandoffRecordDTO{ + ID: record.ID, + OrderID: record.OrderID, + FromUserID: record.FromUserID, + ToUserID: record.ToUserID, + Type: record.Type, + Content: record.Content, + ConfirmedByRenterAt: record.ConfirmedByRenterAt, + ConfirmedByOwnerAt: record.ConfirmedByOwnerAt, + CreatedAt: record.CreatedAt, + } +} + +func makeAccountSnapshot(account model.GameAccount) (datatypes.JSON, error) { + payload := map[string]any{ + "account_id": account.ID, + "title": account.Title, + "game_name": account.GameName, + "server_region": account.ServerRegion, + "login_platform": account.LoginPlatform, + "rank_level": account.RankLevel, + "haf_coin_amount": account.HafCoinAmount, + "asset_summary": account.AssetSummary, + "season_tags": account.SeasonTags, + "screenshot_urls": account.ScreenshotURLS, + "snapshot_version": 1, + } + raw, err := json.Marshal(payload) + return datatypes.JSON(raw), err +} + +func newOrderNo() (string, error) { + buf := make([]byte, 4) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return fmt.Sprintf("RO%d%s", time.Now().UnixNano(), hex.EncodeToString(buf)), nil +} + +func IsNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go new file mode 100644 index 0000000..954fea2 --- /dev/null +++ b/backend/internal/modules/order/service.go @@ -0,0 +1,77 @@ +package order + +import "errors" + +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrInvalidRentHours = errors.New("invalid rent hours") + ErrListingUnavailable = errors.New("listing unavailable") + ErrCannotRentOwnListing = errors.New("cannot rent own listing") + ErrOrderCannotCancel = errors.New("order cannot cancel") + ErrOrderCannotHandoff = errors.New("order cannot handoff") + ErrOrderCannotReceive = errors.New("order cannot receive") + ErrPermissionDenied = errors.New("permission denied") +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Create(userID uint64, req CreateRequest) (*OrderDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.ListingID == 0 || req.RentHours <= 0 { + return nil, ErrInvalidRentHours + } + return s.repo.Create(userID, req) +} + +func (s *Service) Cancel(userID uint64, orderID uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.Cancel(userID, orderID) +} + +func (s *Service) SubmitHandoff(userID uint64, orderID uint64, req SubmitHandoffRequest) (*HandoffRecordDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if orderID == 0 || req.Content == "" { + return nil, ErrOrderCannotHandoff + } + return s.repo.SubmitHandoff(userID, orderID, req) +} + +func (s *Service) ConfirmReceive(userID uint64, orderID uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.ConfirmReceive(userID, orderID) +} + +func (s *Service) HandoffRecords(userID uint64, orderID uint64) ([]HandoffRecordDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.HandoffRecords(userID, orderID) +} + +func (s *Service) ListForUser(userID uint64) ([]OrderDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListForUser(userID) +} + +func (s *Service) FindForUser(userID uint64, orderID uint64) (*OrderDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindForUser(userID, orderID) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 50c3df1..7d85b5f 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -6,6 +6,7 @@ import ( "hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/modules/auth" "hfb_sys/backend/internal/modules/listing" + "hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/realname" "hfb_sys/backend/internal/modules/user" @@ -45,6 +46,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } listingService := listing.NewService(listingRepo) listingHandler := listing.NewHandler(listingService) + var orderRepo *order.Repository + if deps.DB != nil { + orderRepo = order.NewRepository(deps.DB) + } + orderService := order.NewService(orderRepo) + orderHandler := order.NewHandler(orderService) requireAuth := middleware.Auth(jwtManager) requireRealname := middleware.RequireRealname(userRepo) @@ -78,6 +85,17 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { sellerRoutes.GET("/listings/:id", listingHandler.FindMine) } + orderRoutes := api.Group("/orders", requireAuth) + { + orderRoutes.POST("", orderHandler.Create) + orderRoutes.GET("", orderHandler.List) + orderRoutes.GET("/:id", orderHandler.Detail) + orderRoutes.POST("/:id/cancel", orderHandler.Cancel) + orderRoutes.POST("/:id/handoff", orderHandler.SubmitHandoff) + orderRoutes.GET("/:id/handoff-records", orderHandler.HandoffRecords) + orderRoutes.POST("/:id/confirm-receive", orderHandler.ConfirmReceive) + } + realnameRoutes := api.Group("/realname", requireAuth) { realnameRoutes.POST("/start", realnameHandler.Start) diff --git a/docs/api.md b/docs/api.md index 6417b4d..be46637 100644 --- a/docs/api.md +++ b/docs/api.md @@ -30,3 +30,10 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `DELETE /api/listings/{id}` - `GET /api/seller/listings` - `GET /api/seller/listings/{id}` +- `POST /api/orders` +- `GET /api/orders` +- `GET /api/orders/{id}` +- `POST /api/orders/{id}/cancel` +- `POST /api/orders/{id}/handoff` +- `GET /api/orders/{id}/handoff-records` +- `POST /api/orders/{id}/confirm-receive` diff --git a/docs/business-rules.md b/docs/business-rules.md index a1f571f..85feb33 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -26,3 +26,22 @@ - 哈夫币数量是号主手动填报值,不代表实时值。 - 当前提交审核会 mock 为自动通过并上架,后续再接后台人工审核。 - 公开列表只展示 `status = published` 且 `review_status = approved` 的发布。 + +## 开发态订单创建 + +- 创建订单需要登录。 +- 一期创建订单暂不接真实支付和押金冻结,创建成功后直接进入 `pending_handoff`。 +- 创建订单时会在数据库事务内锁定发布和账号,防止同一账号重复出租。 +- 创建订单时生成 `account_snapshot`,记录下单时账号资产状态。 +- 订单创建后发布状态变为 `rented`,不再出现在公开租号列表。 +- 待交接订单可以由租客取消,取消后发布状态恢复为 `published`。 + +## 开发态账号交接 + +- 创建订单后状态为 `pending_handoff`,交接状态为 `pending_owner`。 +- 只有号主可以提交交接说明。 +- 号主提交交接说明后,交接状态变为 `pending_renter_confirm`。 +- 只有租客可以确认收号。 +- 租客确认收号后,订单状态变为 `renting`,交接状态变为 `received`。 +- 确认收号时会重新计算租赁开始时间和结束时间。 +- 交接记录保存在 `handoff_records`,订单双方都可以查看。 diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts new file mode 100644 index 0000000..cc30549 --- /dev/null +++ b/frontend/src/api/orders.ts @@ -0,0 +1,81 @@ +import { apiClient } from './client' + +export interface Order { + id: number + order_no: string + listing_id: number + account_id: number + owner_id: number + renter_id: number + title: string + server_region: string + login_platform: string + rent_start_at?: string + rent_end_at?: string + rent_hours: number + rent_amount: number + deposit_amount: number + platform_fee: number + account_snapshot?: Record + status: string + handoff_status: string + settlement_status: string + created_at: string + updated_at: string +} + +export interface HandoffRecord { + id: number + order_id: number + from_user_id: number + to_user_id: number + type: string + content: string + confirmed_by_renter_at?: string + confirmed_by_owner_at?: string + created_at: string +} + +interface ApiResponse { + code: string + message: string + data: T +} + +export async function createOrder(listingId: number, rentHours: number) { + const { data } = await apiClient.post>('/orders', { + listing_id: listingId, + rent_hours: rentHours, + }) + return data.data +} + +export async function fetchOrders() { + const { data } = await apiClient.get>('/orders') + return data.data.items +} + +export async function fetchOrder(id: string | number) { + const { data } = await apiClient.get>(`/orders/${id}`) + return data.data +} + +export async function cancelOrder(id: number) { + const { data } = await apiClient.post>(`/orders/${id}/cancel`) + return data.data +} + +export async function submitHandoff(id: number, content: string) { + const { data } = await apiClient.post>(`/orders/${id}/handoff`, { content }) + return data.data +} + +export async function fetchHandoffRecords(id: string | number) { + const { data } = await apiClient.get>(`/orders/${id}/handoff-records`) + return data.data.items +} + +export async function confirmReceive(id: number) { + const { data } = await apiClient.post>(`/orders/${id}/confirm-receive`) + return data.data +} diff --git a/frontend/src/stores/session.ts b/frontend/src/stores/session.ts index fca6291..9d12606 100644 --- a/frontend/src/stores/session.ts +++ b/frontend/src/stores/session.ts @@ -6,6 +6,7 @@ export const useSessionStore = defineStore('session', { state: () => ({ token: localStorage.getItem('access_token') || '', refreshToken: localStorage.getItem('refresh_token') || '', + userId: Number(localStorage.getItem('user_id') || 0), phone: '', realnameStatus: 'unknown', }), @@ -23,19 +24,25 @@ export const useSessionStore = defineStore('session', { logout() { this.token = '' this.refreshToken = '' + this.userId = 0 this.phone = '' this.realnameStatus = 'unknown' localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') + localStorage.removeItem('user_id') }, applySession(user: AuthUser, accessToken: string, refreshToken: string) { this.token = accessToken this.refreshToken = refreshToken + this.userId = user.id localStorage.setItem('access_token', accessToken) localStorage.setItem('refresh_token', refreshToken) + localStorage.setItem('user_id', String(user.id)) this.applyUser(user) }, applyUser(user: AuthUser) { + this.userId = user.id + localStorage.setItem('user_id', String(user.id)) this.phone = user.phone this.realnameStatus = user.realname_status }, diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 69ab8c3..c08a3b8 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -266,6 +266,47 @@ h1 { column-gap: 16px; } +.order-panel { + max-width: 520px; + margin-top: 28px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 20px; +} + +.order-panel p { + margin: 0 0 12px; + color: #52616f; +} + +.order-panel h2 { + margin: 0 0 14px; + font-size: 18px; +} + +.timeline-item { + border-top: 1px solid #e4e7ed; + padding: 14px 0; +} + +.timeline-item:first-of-type { + border-top: 0; +} + +.timeline-item strong { + display: block; +} + +.timeline-item span { + color: #6b7785; + font-size: 13px; +} + +.panel-action { + margin-top: 14px; +} + @media (max-width: 760px) { .app-shell { grid-template-columns: 1fr; diff --git a/frontend/src/views/account/OrderDetailView.vue b/frontend/src/views/account/OrderDetailView.vue index f63835f..b2fd09e 100644 --- a/frontend/src/views/account/OrderDetailView.vue +++ b/frontend/src/views/account/OrderDetailView.vue @@ -1,9 +1,156 @@ + + diff --git a/frontend/src/views/account/OrdersView.vue b/frontend/src/views/account/OrdersView.vue index 225d731..910ffa7 100644 --- a/frontend/src/views/account/OrdersView.vue +++ b/frontend/src/views/account/OrdersView.vue @@ -1,3 +1,23 @@ + + diff --git a/frontend/src/views/public/ListingDetailView.vue b/frontend/src/views/public/ListingDetailView.vue index cecfb1a..86a7c3f 100644 --- a/frontend/src/views/public/ListingDetailView.vue +++ b/frontend/src/views/public/ListingDetailView.vue @@ -1,11 +1,16 @@