From 3f5f18b43e24163d9eaa9648506e01ff7f2bd891 Mon Sep 17 00:00:00 2001 From: yml Date: Fri, 22 May 2026 17:04:28 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=205=20=E9=98=B6=E6=AE=B5=EF=BC=9A?= =?UTF-8?q?=E7=BA=A0=E7=BA=B7=E3=80=81=E9=80=9A=E7=9F=A5=E4=B8=8E=E5=90=8E?= =?UTF-8?q?=E5=8F=B0-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- backend/internal/modules/listing/dto.go | 4 + backend/internal/modules/listing/handler.go | 40 ++++++ .../internal/modules/listing/repository.go | 124 ++++++++++++++++-- backend/internal/modules/listing/service.go | 24 ++++ backend/internal/router/router.go | 3 + docs/api.md | 3 + docs/business-rules.md | 4 +- frontend/src/api/listings.ts | 15 +++ frontend/src/layouts/AppLayout.vue | 3 +- frontend/src/router/index.ts | 1 + .../src/views/admin/AdminDashboardView.vue | 8 +- .../views/admin/AdminListingReviewView.vue | 107 +++++++++++++++ .../src/views/seller/SellerListingsView.vue | 11 +- 14 files changed, 332 insertions(+), 18 deletions(-) create mode 100644 frontend/src/views/admin/AdminListingReviewView.vue diff --git a/README.md b/README.md index 5d8f782..b5c3d38 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ npm run dev - 短信验证码使用 mock 适配器,验证码会打印在后端日志中。 - 实名认证使用 mock 适配器,登录后请求 `POST /api/realname/start`,提交合法姓名和 18 位身份证号会直接通过。 - 实名状态可通过 `GET /api/realname/status` 查询。 -- 租号发布需要登录并完成实名认证;开发态 `POST /api/listings/{id}/submit-review` 会自动审核通过并上架。 +- 租号发布需要登录并完成实名认证;`POST /api/listings/{id}/submit-review` 会进入待审核,后台通过后才上架。 - 订单创建需要登录;开发态会直接锁定账号并进入待交接,暂不接真实支付和押金冻结。 - 账号交接已支持号主提交说明、租客确认收号,确认后订单进入租赁中。 - 归还流程已支持租客提交归还、号主确认归还,完成后账号重新上架。 @@ -39,6 +39,7 @@ npm run dev - 系统配置已支持默认配置初始化和后台编辑,页面为 `http://localhost:5173/admin/system-configs`,更新会写入审计日志。 - 后台已使用独立登录,页面为 `http://localhost:5173/admin/login`;开发态默认管理员为 `admin / admin123456`。 - 后台仪表盘已接入真实统计数据,页面为 `http://localhost:5173/admin/dashboard`。 +- 商品审核后台已接入,页面为 `http://localhost:5173/admin/listings/review`。 ## 文档 diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 18d6d4c..11aaf68 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -43,3 +43,7 @@ type CreateRequest struct { } type UpdateRequest = CreateRequest + +type ReviewRequest struct { + Reason string `json:"reason"` +} diff --git a/backend/internal/modules/listing/handler.go b/backend/internal/modules/listing/handler.go index 0edb670..b80faee 100644 --- a/backend/internal/modules/listing/handler.go +++ b/backend/internal/modules/listing/handler.go @@ -79,6 +79,46 @@ func (h *Handler) SubmitReview(c *gin.Context) { response.OK(c, item) } +func (h *Handler) ListPendingReview(c *gin.Context) { + items, err := h.service.ListPendingReview() + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, gin.H{"items": items}) +} + +func (h *Handler) Approve(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.Approve(id) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + +func (h *Handler) Reject(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req ReviewRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "审核拒绝原因不能为空") + return + } + item, err := h.service.Reject(id, req) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + func (h *Handler) Offline(c *gin.Context) { ownerID, ok := currentUserID(c) if !ok { diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 801ad02..7859e3b 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -5,8 +5,10 @@ import ( "time" "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/notification" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type Repository struct { @@ -102,6 +104,49 @@ func (r *Repository) SubmitReview(ownerID uint64, listingID uint64) (*ListingDTO if err != nil { return err } + if listing.Status == "rented" { + return ErrListingLocked + } + listing.Status = "draft" + listing.ReviewStatus = "pending" + listing.ReviewReason = "" + listing.PublishedAt = nil + account.Status = "draft" + 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) ListPendingReview() ([]ListingDTO, error) { + var rows []listingRow + err := r.baseQuery(). + Where("l.review_status = ?", "pending"). + Order("l.updated_at ASC, l.id ASC"). + Limit(200). + Scan(&rows).Error + if err != nil { + return nil, err + } + return rowsToDTO(rows), nil +} + +func (r *Repository) Approve(listingID uint64) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findForReviewUpdate(tx, listingID) + if err != nil { + return err + } + if listing.Status == "rented" { + return ErrListingLocked + } now := time.Now() listing.Status = "published" listing.ReviewStatus = "approved" @@ -114,6 +159,55 @@ func (r *Repository) SubmitReview(ownerID uint64, listingID uint64) (*ListingDTO if err := tx.Save(listing).Error; err != nil { return err } + listingID := listing.ID + if err := notification.Append(tx, notification.Entry{ + UserID: listing.OwnerID, + Type: "listing_review", + Title: "发布审核通过", + Content: "你的租号发布已审核通过并上架。", + BizType: "listing", + BizID: &listingID, + }); err != nil { + return err + } + dto = toDTO(*account, *listing) + return nil + }) + return dto, err +} + +func (r *Repository) Reject(listingID uint64, req ReviewRequest) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findForReviewUpdate(tx, listingID) + if err != nil { + return err + } + if listing.Status == "rented" { + return ErrListingLocked + } + listing.Status = "draft" + listing.ReviewStatus = "rejected" + listing.ReviewReason = req.Reason + listing.PublishedAt = nil + account.Status = "draft" + if err := tx.Save(account).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + listingID := listing.ID + if err := notification.Append(tx, notification.Entry{ + UserID: listing.OwnerID, + Type: "listing_review", + Title: "发布审核未通过", + Content: "你的租号发布未通过审核,请根据原因修改后重新提交。", + BizType: "listing", + BizID: &listingID, + }); err != nil { + return err + } dto = toDTO(*account, *listing) return nil }) @@ -140,9 +234,7 @@ func (r *Repository) Offline(ownerID uint64, listingID uint64) 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"). + err := r.baseQuery(). Where("l.status = ? AND l.review_status = ?", "published", "approved"). Order("l.published_at DESC, l.id DESC"). Limit(100). @@ -155,9 +247,7 @@ func (r *Repository) ListPublic() ([]ListingDTO, error) { 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"). + err := r.baseQuery(). Where("l.owner_id = ?", ownerID). Order("l.id DESC"). Scan(&rows).Error @@ -189,9 +279,7 @@ func (r *Repository) findOwnedForUpdate(tx *gorm.DB, ownerID uint64, listingID u 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"). + err := r.baseQuery(). Where(where, args...). First(&row).Error if err != nil { @@ -201,6 +289,24 @@ func (r *Repository) findDTO(where string, args ...any) (*ListingDTO, error) { return &dto, nil } +func (r *Repository) baseQuery() *gorm.DB { + return 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") +} + +func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model.RentalListing, *model.GameAccount, error) { + var listing model.RentalListing + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, listingID).Error; err != nil { + return nil, nil, err + } + var account model.GameAccount + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&account, listing.AccountID).Error; err != nil { + return nil, nil, err + } + return &listing, &account, nil +} + type listingRow struct { model.RentalListing Title string diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index 0aed022..5d81caa 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -45,6 +45,30 @@ func (s *Service) SubmitReview(ownerID uint64, id uint64) (*ListingDTO, error) { return s.repo.SubmitReview(ownerID, id) } +func (s *Service) ListPendingReview() ([]ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListPendingReview() +} + +func (s *Service) Approve(id uint64) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.Approve(id) +} + +func (s *Service) Reject(id uint64, req ReviewRequest) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.Reason == "" { + return nil, ErrInvalidInput + } + return s.repo.Reject(id, req) +} + func (s *Service) Offline(ownerID uint64, id uint64) error { if s.repo == nil { return ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 954b814..64ad49a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -176,6 +176,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/me", adminAuthHandler.Me) adminRoutes.POST("/auth/logout", adminAuthHandler.Logout) adminRoutes.GET("/dashboard", adminDashboardHandler.Summary) + adminRoutes.GET("/listings/pending", listingHandler.ListPendingReview) + adminRoutes.POST("/listings/:id/approve", listingHandler.Approve) + adminRoutes.POST("/listings/:id/reject", listingHandler.Reject) adminRoutes.GET("/disputes", disputeHandler.AdminList) adminRoutes.POST("/disputes/:id/arbitrate", disputeHandler.AdminArbitrate) adminRoutes.GET("/system-configs", systemConfigHandler.List) diff --git a/docs/api.md b/docs/api.md index b8b0cce..6f82592 100644 --- a/docs/api.md +++ b/docs/api.md @@ -50,6 +50,9 @@ API 规划以 [项目计划](project-plan.md) 第 10 章为准。 - `POST /api/admin/auth/logout` - `GET /api/admin/me` - `GET /api/admin/dashboard` +- `GET /api/admin/listings/pending` +- `POST /api/admin/listings/{id}/approve` +- `POST /api/admin/listings/{id}/reject` - `GET /api/admin/disputes` - `POST /api/admin/disputes/{id}/arbitrate` - `GET /api/admin/system-configs` diff --git a/docs/business-rules.md b/docs/business-rules.md index d190d45..d353188 100644 --- a/docs/business-rules.md +++ b/docs/business-rules.md @@ -24,7 +24,9 @@ - 发布租号前必须登录并完成实名认证。 - 一期创建发布时同时创建 `game_accounts` 和 `rental_listings`。 - 哈夫币数量是号主手动填报值,不代表实时值。 -- 当前提交审核会 mock 为自动通过并上架,后续再接后台人工审核。 +- 当前提交审核会进入 `review_status = pending`,由后台商品审核通过后才上架。 +- 后台审核通过后,发布状态变为 `published`,审核状态变为 `approved`,账号状态变为 `published`。 +- 后台审核拒绝后,发布保留为草稿,审核状态变为 `rejected`,拒绝原因写入 `review_reason`。 - 公开列表只展示 `status = published` 且 `review_status = approved` 的发布。 ## 开发态订单创建 diff --git a/frontend/src/api/listings.ts b/frontend/src/api/listings.ts index 2f2336e..7441bea 100644 --- a/frontend/src/api/listings.ts +++ b/frontend/src/api/listings.ts @@ -75,3 +75,18 @@ export async function offlineListing(id: number) { const { data } = await apiClient.delete>(`/listings/${id}`) return data.data } + +export async function fetchPendingReviewListings() { + const { data } = await apiClient.get>('/admin/listings/pending') + return data.data.items +} + +export async function approveListing(id: number) { + const { data } = await apiClient.post>(`/admin/listings/${id}/approve`) + return data.data +} + +export async function rejectListing(id: number, reason: string) { + const { data } = await apiClient.post>(`/admin/listings/${id}/reject`, { reason }) + return data.data +} diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index 14c2a8b..fe40ecc 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -1,5 +1,5 @@ + + diff --git a/frontend/src/views/seller/SellerListingsView.vue b/frontend/src/views/seller/SellerListingsView.vue index 6044e48..657f5ba 100644 --- a/frontend/src/views/seller/SellerListingsView.vue +++ b/frontend/src/views/seller/SellerListingsView.vue @@ -20,7 +20,7 @@ async function loadListings() { async function submitReview(id: number) { await submitListingReview(id) - ElMessage.success('已提交审核并自动上架') + ElMessage.success('已提交审核,等待后台处理') await loadListings() } @@ -52,10 +52,13 @@ async function offline(id: number) { - + +