第 3 阶段:租号发布与审核
This commit is contained in:
@@ -29,6 +29,7 @@ npm run dev
|
||||
- 短信验证码使用 mock 适配器,验证码会打印在后端日志中。
|
||||
- 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。
|
||||
- 实名状态可通过 `GET /api/realname/status` 查询。
|
||||
- 租号发布需要登录并完成实名认证;开发态 `POST /api/listings/{id}/submit-review` 会自动审核通过并上架。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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", "发布服务暂时不可用")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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` 的发布。
|
||||
|
||||
@@ -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<T> {
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function fetchListings() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/listings')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function fetchListing(id: string | number) {
|
||||
const { data } = await apiClient.get<ApiResponse<Listing>>(`/listings/${id}`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchSellerListings() {
|
||||
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/seller/listings')
|
||||
return data.data.items
|
||||
}
|
||||
|
||||
export async function createListing(payload: ListingPayload) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>('/listings', payload)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function submitListingReview(id: number) {
|
||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/listings/${id}/submit-review`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function offlineListing(id: number) {
|
||||
const { data } = await apiClient.delete<ApiResponse<{ offline: boolean }>>(`/listings/${id}`)
|
||||
return data.data
|
||||
}
|
||||
@@ -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') },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { fetchListing, type Listing } from '@/api/listings'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(false)
|
||||
const listing = ref<Listing | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
listing.value = await fetchListing(String(route.params.id))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Listing Detail</p>
|
||||
<h1>租号详情</h1>
|
||||
<p>展示账号资产、哈夫币快照、租金押金、号主信用和风险提示。</p>
|
||||
<section class="page" v-loading="loading">
|
||||
<div v-if="listing" class="page-header">
|
||||
<p class="eyebrow">{{ listing.server_region }} / {{ listing.login_platform }}</p>
|
||||
<h1>{{ listing.title }}</h1>
|
||||
<p>{{ listing.description || '号主暂未填写详细说明。' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="listing" class="detail-grid">
|
||||
<div class="metric-card">
|
||||
<span>哈夫币</span>
|
||||
<strong>{{ listing.haf_coin_amount }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>时租</span>
|
||||
<strong>¥{{ listing.price_hourly }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>押金</span>
|
||||
<strong>¥{{ listing.deposit_amount }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>租期</span>
|
||||
<strong>{{ listing.min_rent_hours }}-{{ listing.max_rent_hours }} 小时</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchListings, type Listing } from '@/api/listings'
|
||||
|
||||
const loading = ref(false)
|
||||
const listings = ref<Listing[]>([])
|
||||
|
||||
onMounted(loadListings)
|
||||
|
||||
async function loadListings() {
|
||||
loading.value = true
|
||||
try {
|
||||
listings.value = await fetchListings()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Listings</p>
|
||||
<h1>租号列表</h1>
|
||||
<p>后续支持区服、平台、价格、押金、段位和哈夫币数量筛选。</p>
|
||||
<p>支持区服、平台、价格、押金、段位和哈夫币数量展示。</p>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && listings.length === 0" description="暂无已上架租号" />
|
||||
<div v-else v-loading="loading" class="listing-grid">
|
||||
<RouterLink v-for="item in listings" :key="item.id" class="listing-card" :to="`/listings/${item.id}`">
|
||||
<span>{{ item.server_region }} / {{ item.login_platform }}</span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<p>{{ item.rank_level || '未填写段位' }} · 哈夫币 {{ item.haf_coin_amount }}</p>
|
||||
<div class="listing-price">
|
||||
<b>¥{{ item.price_hourly }}/小时</b>
|
||||
<em>押金 ¥{{ item.deposit_amount }}</em>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { createListing } from '@/api/listings'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
server_region: '微信区',
|
||||
login_platform: 'QQ',
|
||||
rank_level: '',
|
||||
haf_coin_amount: 0,
|
||||
price_hourly: 5,
|
||||
price_daily: 50,
|
||||
price_weekly: 300,
|
||||
deposit_amount: 100,
|
||||
min_rent_hours: 1,
|
||||
max_rent_hours: 24,
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
loading.value = true
|
||||
try {
|
||||
await createListing({ ...form })
|
||||
ElMessage.success('发布已创建')
|
||||
await router.push('/seller/listings')
|
||||
} catch (error) {
|
||||
ElMessage.error(readError(error, '发布失败,请确认已登录并完成实名认证'))
|
||||
} finally {
|
||||
loading.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
|
||||
return response?.data?.message || fallback
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
<p class="eyebrow">Create Listing</p>
|
||||
<h1>发布账号</h1>
|
||||
<p>一期先记录账号资产说明和哈夫币填报值,下单时会生成订单资产快照。</p>
|
||||
</div>
|
||||
|
||||
<el-form class="listing-form" label-position="top">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="form.title" placeholder="例如:高段位哈夫币充足账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="账号说明">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="区服">
|
||||
<el-input v-model="form.server_region" />
|
||||
</el-form-item>
|
||||
<el-form-item label="平台">
|
||||
<el-input v-model="form.login_platform" />
|
||||
</el-form-item>
|
||||
<el-form-item label="段位">
|
||||
<el-input v-model="form.rank_level" />
|
||||
</el-form-item>
|
||||
<el-form-item label="哈夫币数量">
|
||||
<el-input-number v-model="form.haf_coin_amount" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时租">
|
||||
<el-input-number v-model="form.price_hourly" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="日租">
|
||||
<el-input-number v-model="form.price_daily" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="周租">
|
||||
<el-input-number v-model="form.price_weekly" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="押金">
|
||||
<el-input-number v-model="form.deposit_amount" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最短租期(小时)">
|
||||
<el-input-number v-model="form.min_rent_hours" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最长租期(小时)">
|
||||
<el-input-number v-model="form.max_rent_hours" :min="form.min_rent_hours" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">保存发布</el-button>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,9 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { fetchSellerListings, offlineListing, submitListingReview, type Listing } from '@/api/listings'
|
||||
|
||||
const loading = ref(false)
|
||||
const listings = ref<Listing[]>([])
|
||||
|
||||
onMounted(loadListings)
|
||||
|
||||
async function loadListings() {
|
||||
loading.value = true
|
||||
try {
|
||||
listings.value = await fetchSellerListings()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReview(id: number) {
|
||||
await submitListingReview(id)
|
||||
ElMessage.success('已提交审核并自动上架')
|
||||
await loadListings()
|
||||
}
|
||||
|
||||
async function offline(id: number) {
|
||||
await offlineListing(id)
|
||||
ElMessage.success('已下架')
|
||||
await loadListings()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
<div class="page-header page-header-row">
|
||||
<div>
|
||||
<p class="eyebrow">Seller</p>
|
||||
<h1>我的发布</h1>
|
||||
<p>管理账号发布、审核状态、上架和下架。</p>
|
||||
</div>
|
||||
<RouterLink to="/seller/listings/create">
|
||||
<el-button type="primary">发布账号</el-button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" class="table-panel" :data="listings">
|
||||
<el-table-column prop="title" label="标题" min-width="180" />
|
||||
<el-table-column prop="server_region" label="区服" width="120" />
|
||||
<el-table-column prop="haf_coin_amount" label="哈夫币" width="120" />
|
||||
<el-table-column prop="price_hourly" label="时租" width="100" />
|
||||
<el-table-column prop="deposit_amount" label="押金" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="110" />
|
||||
<el-table-column prop="review_status" label="审核" width="110" />
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="submitReview(row.id)">上架</el-button>
|
||||
<el-button size="small" type="danger" @click="offline(row.id)">下架</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user