第 5 阶段:纠纷、通知与后台-4

This commit is contained in:
yml
2026-05-22 17:04:28 +08:00
parent e72decebc8
commit 3f5f18b43e
14 changed files with 332 additions and 18 deletions
+2 -1
View File
@@ -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`
## 文档
+4
View File
@@ -43,3 +43,7 @@ type CreateRequest struct {
}
type UpdateRequest = CreateRequest
type ReviewRequest struct {
Reason string `json:"reason"`
}
@@ -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 {
+115 -9
View File
@@ -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
@@ -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
+3
View File
@@ -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)
+3
View File
@@ -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`
+3 -1
View File
@@ -24,7 +24,9 @@
- 发布租号前必须登录并完成实名认证。
- 一期创建发布时同时创建 `game_accounts``rental_listings`
- 哈夫币数量是号主手动填报值,不代表实时值。
- 当前提交审核会 mock 为自动通过并上架,后续再接后台人工审核
- 当前提交审核会进入 `review_status = pending`,由后台商品审核通过后才上架
- 后台审核通过后,发布状态变为 `published`,审核状态变为 `approved`,账号状态变为 `published`
- 后台审核拒绝后,发布保留为草稿,审核状态变为 `rejected`,拒绝原因写入 `review_reason`
- 公开列表只展示 `status = published``review_status = approved` 的发布。
## 开发态订单创建
+15
View File
@@ -75,3 +75,18 @@ export async function offlineListing(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ offline: boolean }>>(`/listings/${id}`)
return data.data
}
export async function fetchPendingReviewListings() {
const { data } = await apiClient.get<ApiResponse<{ items: Listing[] }>>('/admin/listings/pending')
return data.data.items
}
export async function approveListing(id: number) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/approve`)
return data.data
}
export async function rejectListing(id: number, reason: string) {
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/reject`, { reason })
return data.data
}
+2 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { Bell, House, Key, Operation, Phone, ScaleToOriginal, Shop, Tickets, UserFilled, Wallet } from '@element-plus/icons-vue'
import { Bell, House, Key, Operation, Phone, ScaleToOriginal, Shop, Tickets, UserFilled, Wallet, View } from '@element-plus/icons-vue'
const navItems = [
{ label: '首页', to: '/', icon: House },
@@ -9,6 +9,7 @@ const navItems = [
{ label: '通知', to: '/notifications', icon: Bell },
{ label: '实名', to: '/realname', icon: UserFilled },
{ label: '后台登录', to: '/admin/login', icon: Key },
{ label: '审核', to: '/admin/listings/review', icon: View },
{ label: '仲裁', to: '/admin/disputes', icon: ScaleToOriginal },
{ label: '配置', to: '/admin/system-configs', icon: Operation },
{ label: '登录', to: '/login', icon: Phone },
+1
View File
@@ -19,6 +19,7 @@ const router = createRouter({
{ path: '/seller/earnings', name: 'seller-earnings', component: () => import('@/views/seller/SellerEarningsView.vue') },
{ path: '/admin/login', name: 'admin-login', component: () => import('@/views/admin/AdminLoginView.vue') },
{ path: '/admin/dashboard', name: 'admin-dashboard', component: () => import('@/views/admin/AdminDashboardView.vue') },
{ path: '/admin/listings/review', name: 'admin-listing-review', component: () => import('@/views/admin/AdminListingReviewView.vue') },
{ path: '/admin/disputes', name: 'admin-disputes', component: () => import('@/views/admin/AdminDisputesView.vue') },
{ path: '/admin/system-configs', name: 'admin-system-configs', component: () => import('@/views/admin/AdminSystemConfigsView.vue') },
],
@@ -75,10 +75,10 @@ function money(value?: number) {
<span>待仲裁申诉</span>
<strong>{{ dashboard.pending.disputes }}</strong>
</RouterLink>
<div class="pending-row">
<RouterLink class="pending-row" to="/admin/listings/review">
<span>待审核商品</span>
<strong>{{ dashboard.pending.listing_reviews }}</strong>
</div>
</RouterLink>
<div class="pending-row">
<span>待交接订单</span>
<strong>{{ dashboard.pending.pending_handoffs }}</strong>
@@ -95,6 +95,10 @@ function money(value?: number) {
<span>仲裁中心</span>
<strong>进入</strong>
</RouterLink>
<RouterLink class="pending-row" to="/admin/listings/review">
<span>商品审核</span>
<strong>进入</strong>
</RouterLink>
<RouterLink class="pending-row" to="/admin/system-configs">
<span>系统配置</span>
<strong>进入</strong>
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { approveListing, fetchPendingReviewListings, rejectListing, type Listing } from '@/api/listings'
const loading = ref(false)
const submitting = ref(false)
const listings = ref<Listing[]>([])
const activeListing = ref<Listing | null>(null)
const rejectReason = ref('')
onMounted(loadListings)
async function loadListings() {
loading.value = true
try {
listings.value = await fetchPendingReviewListings()
} finally {
loading.value = false
}
}
async function handleApprove(row: Listing) {
submitting.value = true
try {
await approveListing(row.id)
ElMessage.success('审核通过,商品已上架')
await loadListings()
} catch (error) {
ElMessage.error(readError(error, '审核失败'))
} finally {
submitting.value = false
}
}
function openReject(row: Listing) {
activeListing.value = row
rejectReason.value = row.review_reason || ''
}
async function handleReject() {
if (!activeListing.value) return
submitting.value = true
try {
await rejectListing(activeListing.value.id, rejectReason.value)
ElMessage.success('已拒绝发布并通知号主')
activeListing.value = null
rejectReason.value = ''
await loadListings()
} catch (error) {
ElMessage.error(readError(error, '拒绝失败'))
} finally {
submitting.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-row">
<div class="page-header">
<p class="eyebrow">Review</p>
<h1>商品审核</h1>
<p>审核号主提交的账号资产价格押金和租期规则</p>
</div>
<el-button @click="loadListings">刷新</el-button>
</div>
<el-table v-loading="loading" class="table-panel" :data="listings">
<el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="owner_id" label="号主" width="90" />
<el-table-column prop="server_region" label="区服" width="120" />
<el-table-column prop="login_platform" label="平台" width="120" />
<el-table-column prop="haf_coin_amount" label="哈夫币" width="110" />
<el-table-column prop="rank_level" label="段位" width="110" />
<el-table-column prop="price_hourly" label="时租" width="90" />
<el-table-column prop="deposit_amount" label="押金" width="90" />
<el-table-column prop="updated_at" label="提交时间" min-width="180" />
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" :loading="submitting" @click="handleApprove(row)">通过</el-button>
<el-button size="small" type="danger" @click="openReject(row)">拒绝</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog :model-value="!!activeListing" title="拒绝发布" width="560px" @update:model-value="activeListing = null">
<div v-if="activeListing" class="dialog-body">
<p><strong>{{ activeListing.title }}</strong></p>
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="填写拒绝原因,号主会在通知中看到审核结果" />
</div>
<template #footer>
<el-button @click="activeListing = null">取消</el-button>
<el-button type="danger" :loading="submitting" @click="handleReject">确认拒绝</el-button>
</template>
</el-dialog>
</section>
</template>
@@ -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) {
<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">
<el-table-column prop="review_reason" label="审核原因" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="190">
<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>
<el-button size="small" :disabled="row.review_status === 'pending' || row.status === 'rented'" @click="submitReview(row.id)">
提审
</el-button>
<el-button size="small" type="danger" :disabled="row.status === 'rented'" @click="offline(row.id)">下架</el-button>
</template>
</el-table-column>
</el-table>