diff --git a/README.md b/README.md index 8e7021b..de91258 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ npm run dev - 短信验证码使用 mock 适配器,验证码会打印在后端日志中。 - 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。 - 实名状态可通过 `GET /api/realname/status` 查询。 +- 租号发布需要登录并完成实名认证;开发态 `POST /api/listings/{id}/submit-review` 会自动审核通过并上架。 ## 文档 diff --git a/backend/go.mod b/backend/go.mod index cdedea1..ee14c42 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,6 +7,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/redis/go-redis/v9 v9.17.0 go.uber.org/zap v1.27.0 + gorm.io/datatypes v1.2.7 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.1 ) @@ -26,6 +27,7 @@ require ( github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/backend/go.sum b/backend/go.sum index 6ee61f3..c8c08a6 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -39,9 +39,23 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= +github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -54,6 +68,10 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= +github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= @@ -112,7 +130,15 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= +gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= +gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= +gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/backend/internal/model/listing.go b/backend/internal/model/listing.go new file mode 100644 index 0000000..9e84787 --- /dev/null +++ b/backend/internal/model/listing.go @@ -0,0 +1,51 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +type GameAccount struct { + ID uint64 `gorm:"primaryKey" json:"id"` + OwnerID uint64 `gorm:"not null;index" json:"owner_id"` + GameName string `gorm:"size:64;not null;default:'delta_force'" json:"game_name"` + ServerRegion string `gorm:"size:64;not null" json:"server_region"` + LoginPlatform string `gorm:"size:64;not null" json:"login_platform"` + Title string `gorm:"size:128;not null" json:"title"` + Description string `json:"description"` + RankLevel string `gorm:"size:64;not null;default:''" json:"rank_level"` + HafCoinAmount int64 `gorm:"not null;default:0" json:"haf_coin_amount"` + AssetSummary datatypes.JSON `json:"asset_summary"` + SeasonTags datatypes.JSON `json:"season_tags"` + ScreenshotURLS datatypes.JSON `gorm:"column:screenshot_urls" json:"screenshot_urls"` + Status string `gorm:"size:32;not null;default:'draft'" json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (GameAccount) TableName() string { + return "game_accounts" +} + +type RentalListing struct { + ID uint64 `gorm:"primaryKey" json:"id"` + AccountID uint64 `gorm:"not null;index" json:"account_id"` + OwnerID uint64 `gorm:"not null;index" json:"owner_id"` + PriceHourly float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price_hourly"` + PriceDaily float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price_daily"` + PriceWeekly float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price_weekly"` + DepositAmount float64 `gorm:"type:decimal(12,2);not null;default:0" json:"deposit_amount"` + MinRentHours int `gorm:"not null;default:1" json:"min_rent_hours"` + MaxRentHours int `gorm:"not null;default:168" json:"max_rent_hours"` + Status string `gorm:"size:32;not null;default:'draft'" json:"status"` + ReviewStatus string `gorm:"size:32;not null;default:'none'" json:"review_status"` + ReviewReason string `gorm:"size:255;not null;default:''" json:"review_reason"` + PublishedAt *time.Time `json:"published_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (RentalListing) TableName() string { + return "rental_listings" +} diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go new file mode 100644 index 0000000..18d6d4c --- /dev/null +++ b/backend/internal/modules/listing/dto.go @@ -0,0 +1,45 @@ +package listing + +import "time" + +type ListingDTO struct { + ID uint64 `json:"id"` + AccountID uint64 `json:"account_id"` + OwnerID uint64 `json:"owner_id"` + Title string `json:"title"` + Description string `json:"description"` + GameName string `json:"game_name"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + RankLevel string `json:"rank_level"` + HafCoinAmount int64 `json:"haf_coin_amount"` + PriceHourly float64 `json:"price_hourly"` + PriceDaily float64 `json:"price_daily"` + PriceWeekly float64 `json:"price_weekly"` + DepositAmount float64 `json:"deposit_amount"` + MinRentHours int `json:"min_rent_hours"` + MaxRentHours int `json:"max_rent_hours"` + Status string `json:"status"` + ReviewStatus string `json:"review_status"` + ReviewReason string `json:"review_reason"` + PublishedAt *time.Time `json:"published_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreateRequest struct { + Title string `json:"title" binding:"required"` + Description string `json:"description"` + ServerRegion string `json:"server_region" binding:"required"` + LoginPlatform string `json:"login_platform" binding:"required"` + RankLevel string `json:"rank_level"` + HafCoinAmount int64 `json:"haf_coin_amount"` + PriceHourly float64 `json:"price_hourly" binding:"required"` + PriceDaily float64 `json:"price_daily"` + PriceWeekly float64 `json:"price_weekly"` + DepositAmount float64 `json:"deposit_amount" binding:"required"` + MinRentHours int `json:"min_rent_hours" binding:"required"` + MaxRentHours int `json:"max_rent_hours" binding:"required"` +} + +type UpdateRequest = CreateRequest diff --git a/backend/internal/modules/listing/handler.go b/backend/internal/modules/listing/handler.go new file mode 100644 index 0000000..0edb670 --- /dev/null +++ b/backend/internal/modules/listing/handler.go @@ -0,0 +1,184 @@ +package listing + +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) { + ownerID, 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(ownerID, req) + if err != nil { + writeListingError(c, err) + return + } + response.Created(c, item) +} + +func (h *Handler) Update(c *gin.Context) { + ownerID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req UpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "发布信息不完整") + return + } + item, err := h.service.Update(ownerID, id, req) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) SubmitReview(c *gin.Context) { + ownerID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.SubmitReview(ownerID, id) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Offline(c *gin.Context) { + ownerID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + if err := h.service.Offline(ownerID, id); err != nil { + writeListingError(c, err) + return + } + response.OK(c, gin.H{"offline": true}) +} + +func (h *Handler) ListPublic(c *gin.Context) { + items, err := h.service.ListPublic() + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *Handler) FindPublic(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.FindPublic(id) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) ListMine(c *gin.Context) { + ownerID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + items, err := h.service.ListMine(ownerID) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *Handler) FindMine(c *gin.Context) { + ownerID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.FindMine(ownerID, id) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +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 writeListingError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrInvalidInput): + response.BadRequest(c, "发布信息不符合规则") + case errors.Is(err, ErrListingLocked): + response.Error(c, http.StatusConflict, "listing_locked", "当前发布不可修改") + case IsNotFound(err): + response.Error(c, http.StatusNotFound, "not_found", "发布不存在") + default: + response.Error(c, http.StatusInternalServerError, "internal_error", "发布服务暂时不可用") + } +} diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go new file mode 100644 index 0000000..801ad02 --- /dev/null +++ b/backend/internal/modules/listing/repository.go @@ -0,0 +1,279 @@ +package listing + +import ( + "errors" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Create(ownerID uint64, req CreateRequest) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.Transaction(func(tx *gorm.DB) error { + account := model.GameAccount{ + OwnerID: ownerID, + GameName: "delta_force", + ServerRegion: req.ServerRegion, + LoginPlatform: req.LoginPlatform, + Title: req.Title, + Description: req.Description, + RankLevel: req.RankLevel, + HafCoinAmount: req.HafCoinAmount, + Status: "draft", + } + if err := tx.Create(&account).Error; err != nil { + return err + } + listing := model.RentalListing{ + AccountID: account.ID, + OwnerID: ownerID, + PriceHourly: req.PriceHourly, + PriceDaily: req.PriceDaily, + PriceWeekly: req.PriceWeekly, + DepositAmount: req.DepositAmount, + MinRentHours: req.MinRentHours, + MaxRentHours: req.MaxRentHours, + Status: "draft", + ReviewStatus: "none", + } + if err := tx.Create(&listing).Error; err != nil { + return err + } + dto = toDTO(account, listing) + return nil + }) + return dto, err +} + +func (r *Repository) Update(ownerID uint64, listingID uint64, req UpdateRequest) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID) + if err != nil { + return err + } + if listing.Status == "rented" { + return ErrListingLocked + } + + account.Title = req.Title + account.Description = req.Description + account.ServerRegion = req.ServerRegion + account.LoginPlatform = req.LoginPlatform + account.RankLevel = req.RankLevel + account.HafCoinAmount = req.HafCoinAmount + if err := tx.Save(account).Error; err != nil { + return err + } + + listing.PriceHourly = req.PriceHourly + listing.PriceDaily = req.PriceDaily + listing.PriceWeekly = req.PriceWeekly + listing.DepositAmount = req.DepositAmount + listing.MinRentHours = req.MinRentHours + listing.MaxRentHours = req.MaxRentHours + listing.Status = "draft" + listing.ReviewStatus = "none" + listing.ReviewReason = "" + listing.PublishedAt = nil + if err := tx.Save(listing).Error; err != nil { + return err + } + dto = toDTO(*account, *listing) + return nil + }) + return dto, err +} + +func (r *Repository) SubmitReview(ownerID uint64, listingID uint64) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID) + if err != nil { + return err + } + now := time.Now() + listing.Status = "published" + listing.ReviewStatus = "approved" + listing.ReviewReason = "" + listing.PublishedAt = &now + account.Status = "published" + if err := tx.Save(account).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + dto = toDTO(*account, *listing) + return nil + }) + return dto, err +} + +func (r *Repository) Offline(ownerID uint64, listingID uint64) error { + return r.db.Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findOwnedForUpdate(tx, ownerID, listingID) + if err != nil { + return err + } + if listing.Status == "rented" { + return ErrListingLocked + } + listing.Status = "offline" + account.Status = "offline" + if err := tx.Save(account).Error; err != nil { + return err + } + return tx.Save(listing).Error + }) +} + +func (r *Repository) ListPublic() ([]ListingDTO, error) { + var rows []listingRow + err := r.db.Table("rental_listings AS l"). + Select("l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, a.haf_coin_amount"). + Joins("JOIN game_accounts AS a ON a.id = l.account_id"). + Where("l.status = ? AND l.review_status = ?", "published", "approved"). + Order("l.published_at DESC, l.id DESC"). + Limit(100). + Scan(&rows).Error + if err != nil { + return nil, err + } + return rowsToDTO(rows), nil +} + +func (r *Repository) ListMine(ownerID uint64) ([]ListingDTO, error) { + var rows []listingRow + err := r.db.Table("rental_listings AS l"). + Select("l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, a.haf_coin_amount"). + Joins("JOIN game_accounts AS a ON a.id = l.account_id"). + Where("l.owner_id = ?", ownerID). + Order("l.id DESC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + return rowsToDTO(rows), nil +} + +func (r *Repository) FindPublic(id uint64) (*ListingDTO, error) { + return r.findDTO("l.id = ? AND l.status = ? AND l.review_status = ?", id, "published", "approved") +} + +func (r *Repository) FindMine(ownerID uint64, id uint64) (*ListingDTO, error) { + return r.findDTO("l.id = ? AND l.owner_id = ?", id, ownerID) +} + +func (r *Repository) findOwnedForUpdate(tx *gorm.DB, ownerID uint64, listingID uint64) (*model.RentalListing, *model.GameAccount, error) { + var listing model.RentalListing + if err := tx.Where("id = ? AND owner_id = ?", listingID, ownerID).First(&listing).Error; err != nil { + return nil, nil, err + } + var account model.GameAccount + if err := tx.Where("id = ? AND owner_id = ?", listing.AccountID, ownerID).First(&account).Error; err != nil { + return nil, nil, err + } + return &listing, &account, nil +} + +func (r *Repository) findDTO(where string, args ...any) (*ListingDTO, error) { + var row listingRow + err := r.db.Table("rental_listings AS l"). + Select("l.*, a.title, a.description, a.game_name, a.server_region, a.login_platform, a.rank_level, a.haf_coin_amount"). + Joins("JOIN game_accounts AS a ON a.id = l.account_id"). + Where(where, args...). + First(&row).Error + if err != nil { + return nil, err + } + dto := row.toDTO() + return &dto, nil +} + +type listingRow struct { + model.RentalListing + Title string + Description string + GameName string + ServerRegion string + LoginPlatform string + RankLevel string + HafCoinAmount int64 +} + +func rowsToDTO(rows []listingRow) []ListingDTO { + items := make([]ListingDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return items +} + +func (row listingRow) toDTO() ListingDTO { + return ListingDTO{ + ID: row.ID, + AccountID: row.AccountID, + OwnerID: row.OwnerID, + Title: row.Title, + Description: row.Description, + GameName: row.GameName, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + RankLevel: row.RankLevel, + HafCoinAmount: row.HafCoinAmount, + PriceHourly: row.PriceHourly, + PriceDaily: row.PriceDaily, + PriceWeekly: row.PriceWeekly, + DepositAmount: row.DepositAmount, + MinRentHours: row.MinRentHours, + MaxRentHours: row.MaxRentHours, + Status: row.Status, + ReviewStatus: row.ReviewStatus, + ReviewReason: row.ReviewReason, + PublishedAt: row.PublishedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO { + return &ListingDTO{ + ID: listing.ID, + AccountID: account.ID, + OwnerID: listing.OwnerID, + Title: account.Title, + Description: account.Description, + GameName: account.GameName, + ServerRegion: account.ServerRegion, + LoginPlatform: account.LoginPlatform, + RankLevel: account.RankLevel, + HafCoinAmount: account.HafCoinAmount, + PriceHourly: listing.PriceHourly, + PriceDaily: listing.PriceDaily, + PriceWeekly: listing.PriceWeekly, + DepositAmount: listing.DepositAmount, + MinRentHours: listing.MinRentHours, + MaxRentHours: listing.MaxRentHours, + Status: listing.Status, + ReviewStatus: listing.ReviewStatus, + ReviewReason: listing.ReviewReason, + PublishedAt: listing.PublishedAt, + CreatedAt: listing.CreatedAt, + UpdatedAt: listing.UpdatedAt, + } +} + +func IsNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go new file mode 100644 index 0000000..0aed022 --- /dev/null +++ b/backend/internal/modules/listing/service.go @@ -0,0 +1,97 @@ +package listing + +import ( + "errors" +) + +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrInvalidInput = errors.New("invalid listing input") + ErrListingLocked = errors.New("listing locked") +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Create(ownerID uint64, req CreateRequest) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if err := validateRequest(req); err != nil { + return nil, err + } + return s.repo.Create(ownerID, req) +} + +func (s *Service) Update(ownerID uint64, id uint64, req UpdateRequest) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if err := validateRequest(req); err != nil { + return nil, err + } + return s.repo.Update(ownerID, id, req) +} + +func (s *Service) SubmitReview(ownerID uint64, id uint64) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.SubmitReview(ownerID, id) +} + +func (s *Service) Offline(ownerID uint64, id uint64) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + return s.repo.Offline(ownerID, id) +} + +func (s *Service) ListPublic() ([]ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListPublic() +} + +func (s *Service) ListMine(ownerID uint64) ([]ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListMine(ownerID) +} + +func (s *Service) FindPublic(id uint64) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindPublic(id) +} + +func (s *Service) FindMine(ownerID uint64, id uint64) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindMine(ownerID, id) +} + +func validateRequest(req CreateRequest) error { + if req.Title == "" || req.ServerRegion == "" || req.LoginPlatform == "" { + return ErrInvalidInput + } + if req.PriceHourly <= 0 || req.DepositAmount < 0 { + return ErrInvalidInput + } + if req.MinRentHours <= 0 || req.MaxRentHours < req.MinRentHours { + return ErrInvalidInput + } + if req.HafCoinAmount < 0 { + return ErrInvalidInput + } + return nil +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d8d5cf3..50c3df1 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -5,6 +5,7 @@ import ( "hfb_sys/backend/internal/handler" "hfb_sys/backend/internal/middleware" "hfb_sys/backend/internal/modules/auth" + "hfb_sys/backend/internal/modules/listing" "hfb_sys/backend/internal/modules/realname" "hfb_sys/backend/internal/modules/user" @@ -38,7 +39,14 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } realnameService := realname.NewService(realnameRepo, realname.NewMockProvider()) realnameHandler := realname.NewHandler(realnameService) + var listingRepo *listing.Repository + if deps.DB != nil { + listingRepo = listing.NewRepository(deps.DB) + } + listingService := listing.NewService(listingRepo) + listingHandler := listing.NewHandler(listingService) requireAuth := middleware.Auth(jwtManager) + requireRealname := middleware.RequireRealname(userRepo) api := engine.Group("/api") { @@ -54,6 +62,22 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { api.GET("/me", requireAuth, userHandler.Me) + listingRoutes := api.Group("/listings") + { + listingRoutes.GET("", listingHandler.ListPublic) + listingRoutes.GET("/:id", listingHandler.FindPublic) + listingRoutes.POST("", requireAuth, requireRealname, listingHandler.Create) + listingRoutes.PUT("/:id", requireAuth, requireRealname, listingHandler.Update) + listingRoutes.POST("/:id/submit-review", requireAuth, requireRealname, listingHandler.SubmitReview) + listingRoutes.DELETE("/:id", requireAuth, requireRealname, listingHandler.Offline) + } + + sellerRoutes := api.Group("/seller", requireAuth, requireRealname) + { + sellerRoutes.GET("/listings", listingHandler.ListMine) + sellerRoutes.GET("/listings/:id", listingHandler.FindMine) + } + realnameRoutes := api.Group("/realname", requireAuth) { realnameRoutes.POST("/start", realnameHandler.Start) diff --git a/docs/api.md b/docs/api.md index c547d66..6417b4d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -22,3 +22,11 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `GET /api/me` - `POST /api/realname/start` - `GET /api/realname/status` +- `GET /api/listings` +- `GET /api/listings/{id}` +- `POST /api/listings` +- `PUT /api/listings/{id}` +- `POST /api/listings/{id}/submit-review` +- `DELETE /api/listings/{id}` +- `GET /api/seller/listings` +- `GET /api/seller/listings/{id}` diff --git a/docs/business-rules.md b/docs/business-rules.md index 4680e80..a1f571f 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -18,3 +18,11 @@ - 后端只返回脱敏姓名和脱敏证件号。 - 用户表 `realname_status` 会同步更新为 `verified`。 - 后续接入阿里云或腾讯云实名服务时,保持 `POST /api/realname/start` 和 `GET /api/realname/status` 的业务语义不变。 + +## 开发态租号发布 + +- 发布租号前必须登录并完成实名认证。 +- 一期创建发布时同时创建 `game_accounts` 和 `rental_listings`。 +- 哈夫币数量是号主手动填报值,不代表实时值。 +- 当前提交审核会 mock 为自动通过并上架,后续再接后台人工审核。 +- 公开列表只展示 `status = published` 且 `review_status = approved` 的发布。 diff --git a/frontend/src/api/listings.ts b/frontend/src/api/listings.ts new file mode 100644 index 0000000..2f2336e --- /dev/null +++ b/frontend/src/api/listings.ts @@ -0,0 +1,77 @@ +import { apiClient } from './client' + +export interface Listing { + id: number + account_id: number + owner_id: number + title: string + description: string + game_name: string + server_region: string + login_platform: string + rank_level: string + haf_coin_amount: number + price_hourly: number + price_daily: number + price_weekly: number + deposit_amount: number + min_rent_hours: number + max_rent_hours: number + status: string + review_status: string + review_reason: string + published_at?: string + created_at: string + updated_at: string +} + +export interface ListingPayload { + title: string + description: string + server_region: string + login_platform: string + rank_level: string + haf_coin_amount: number + price_hourly: number + price_daily: number + price_weekly: number + deposit_amount: number + min_rent_hours: number + max_rent_hours: number +} + +interface ApiResponse { + code: string + message: string + data: T +} + +export async function fetchListings() { + const { data } = await apiClient.get>('/listings') + return data.data.items +} + +export async function fetchListing(id: string | number) { + const { data } = await apiClient.get>(`/listings/${id}`) + return data.data +} + +export async function fetchSellerListings() { + const { data } = await apiClient.get>('/seller/listings') + return data.data.items +} + +export async function createListing(payload: ListingPayload) { + const { data } = await apiClient.post>('/listings', payload) + return data.data +} + +export async function submitListingReview(id: number) { + const { data } = await apiClient.post>(`/listings/${id}/submit-review`) + return data.data +} + +export async function offlineListing(id: number) { + const { data } = await apiClient.delete>(`/listings/${id}`) + return data.data +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2838f36..07b6926 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -14,6 +14,7 @@ const router = createRouter({ { path: '/notifications', name: 'notifications', component: () => import('@/views/account/NotificationsView.vue') }, { path: '/realname', name: 'realname', component: () => import('@/views/account/RealnameView.vue') }, { path: '/seller/listings', name: 'seller-listings', component: () => import('@/views/seller/SellerListingsView.vue') }, + { path: '/seller/listings/create', name: 'seller-listing-create', component: () => import('@/views/seller/SellerListingCreateView.vue') }, { path: '/seller/handoffs', name: 'seller-handoffs', component: () => import('@/views/seller/SellerHandoffsView.vue') }, { path: '/seller/earnings', name: 'seller-earnings', component: () => import('@/views/seller/SellerEarningsView.vue') }, { path: '/admin/dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/AdminDashboardView.vue') }, diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index f6ee3cc..69ab8c3 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -182,6 +182,90 @@ h1 { color: #52616f; } +.listing-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-top: 28px; +} + +.listing-card { + display: block; + min-height: 172px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 18px; +} + +.listing-card span { + color: #0f766e; + font-size: 13px; + font-weight: 700; +} + +.listing-card strong { + display: block; + margin-top: 10px; + font-size: 20px; +} + +.listing-card p { + color: #52616f; + line-height: 1.6; +} + +.listing-price { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 18px; +} + +.listing-price b { + color: #111827; +} + +.listing-price em { + color: #6b7785; + font-style: normal; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-top: 28px; +} + +.page-header-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + max-width: 100%; +} + +.table-panel, +.listing-form { + margin-top: 28px; +} + +.listing-form { + max-width: 860px; + border: 1px solid #e4e7ed; + border-radius: 8px; + background: #ffffff; + padding: 20px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 16px; +} + @media (max-width: 760px) { .app-shell { grid-template-columns: 1fr; @@ -208,4 +292,14 @@ h1 { .metric-grid { grid-template-columns: 1fr; } + + .listing-grid, + .detail-grid, + .form-grid { + grid-template-columns: 1fr; + } + + .page-header-row { + display: grid; + } } diff --git a/frontend/src/views/public/ListingDetailView.vue b/frontend/src/views/public/ListingDetailView.vue index 2c4f593..cecfb1a 100644 --- a/frontend/src/views/public/ListingDetailView.vue +++ b/frontend/src/views/public/ListingDetailView.vue @@ -1,9 +1,48 @@ + + diff --git a/frontend/src/views/public/ListingsView.vue b/frontend/src/views/public/ListingsView.vue index d725fcc..adb8012 100644 --- a/frontend/src/views/public/ListingsView.vue +++ b/frontend/src/views/public/ListingsView.vue @@ -1,9 +1,42 @@ + + diff --git a/frontend/src/views/seller/SellerListingCreateView.vue b/frontend/src/views/seller/SellerListingCreateView.vue new file mode 100644 index 0000000..f57dc38 --- /dev/null +++ b/frontend/src/views/seller/SellerListingCreateView.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/src/views/seller/SellerListingsView.vue b/frontend/src/views/seller/SellerListingsView.vue index 6ba7e18..6044e48 100644 --- a/frontend/src/views/seller/SellerListingsView.vue +++ b/frontend/src/views/seller/SellerListingsView.vue @@ -1,9 +1,63 @@ + +