From bd6e15179e6a9fd84accd8e2d155b90835bb8bce Mon Sep 17 00:00:00 2001 From: yml Date: Wed, 17 Jun 2026 22:31:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=90=8E=E5=8F=B0=E8=BD=AC?= =?UTF-8?q?=E7=A7=BB=E5=8F=91=E5=B8=83=E6=89=80=E6=9C=89=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/chat/adapter.go | 4 +- .../internal/modules/chat/listing_group.go | 10 +- backend/internal/modules/listing/dto.go | 93 ++++---- .../modules/listing/external_upload.go | 14 +- .../internal/modules/listing/handler_admin.go | 23 ++ .../internal/modules/listing/handler_error.go | 2 + backend/internal/modules/listing/mutation.go | 11 +- .../internal/modules/listing/repository.go | 2 +- backend/internal/modules/listing/review.go | 117 ++++++++++ backend/internal/modules/listing/service.go | 1 + .../modules/listing/service_review.go | 10 + backend/internal/router/router.go | 1 + .../admin/views/AdminListingDetailView.vue | 219 +++++++++++++++++- .../src/features/listings/api/listings.ts | 11 + 14 files changed, 460 insertions(+), 58 deletions(-) diff --git a/backend/internal/modules/chat/adapter.go b/backend/internal/modules/chat/adapter.go index 17907e2..1f6062c 100644 --- a/backend/internal/modules/chat/adapter.go +++ b/backend/internal/modules/chat/adapter.go @@ -15,8 +15,8 @@ func NewListingChatCreatorAdapter() *ListingChatCreatorAdapter { } // EnsureListingConversation 实现接口 -func (a *ListingChatCreatorAdapter) EnsureListingConversation(tx *gorm.DB, listing model.RentalListing) (uint64, error) { - conv, err := EnsureListingConversation(tx, listing) +func (a *ListingChatCreatorAdapter) EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, supportAdminID uint64) (uint64, error) { + conv, err := EnsureListingConversation(tx, listing, supportAdminID) if err != nil { return 0, err } diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index 3b11798..5a15ed1 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -22,7 +22,7 @@ var ( ) // EnsureListingConversation 确保发布群存在(幂等) -func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing) (*model.ChatConversation, error) { +func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, preferredSupportAdminID uint64) (*model.ChatConversation, error) { // 1. 幂等查重 var existing model.ChatConversation err := tx.Where("listing_id = ?", listing.ID).First(&existing).Error @@ -56,8 +56,12 @@ func EnsureListingConversation(tx *gorm.DB, listing model.RentalListing) (*model }, } - // 获取客服 - if supportID := defaultSupportAdminID(tx); supportID > 0 { + // 获取客服。开放上传场景优先拉上传管理员,普通发布回退到默认客服。 + supportID := preferredSupportAdminID + if supportID <= 0 { + supportID = defaultSupportAdminID(tx) + } + if supportID > 0 { participants = append(participants, model.ChatParticipant{ ConversationID: conversation.ID, ParticipantType: "admin", diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index ef7ffb1..2b460b1 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -9,33 +9,33 @@ import ( ) type ListingDTO struct { - ID uint64 `json:"id"` - ListingNo string `json:"listing_no"` - AccountID uint64 `json:"account_id"` - OwnerID uint64 `json:"owner_id"` - OwnerPhone string `json:"owner_phone,omitempty"` - OwnerNickname string `json:"owner_nickname,omitempty"` - 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"` - AssetSummary map[string]any `json:"asset_summary,omitempty"` - ScreenshotURLS []string `json:"screenshot_urls"` - CoverURL string `json:"cover_url"` - PriceCent int64 `json:"price_cent"` - DepositAmountCent int64 `json:"deposit_amount_cent"` - IsAccelerated bool `json:"is_accelerated_sale"` - InTransaction bool `json:"in_transaction"` - Status string `json:"status"` - ReviewStatus string `json:"review_status"` - ReviewReason string `json:"review_reason"` - PublishedAt *time.Time `json:"published_at"` - ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + ListingNo string `json:"listing_no"` + AccountID uint64 `json:"account_id"` + OwnerID uint64 `json:"owner_id"` + OwnerPhone string `json:"owner_phone,omitempty"` + OwnerNickname string `json:"owner_nickname,omitempty"` + 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"` + AssetSummary map[string]any `json:"asset_summary,omitempty"` + ScreenshotURLS []string `json:"screenshot_urls"` + CoverURL string `json:"cover_url"` + PriceCent int64 `json:"price_cent"` + DepositAmountCent int64 `json:"deposit_amount_cent"` + IsAccelerated bool `json:"is_accelerated_sale"` + InTransaction bool `json:"in_transaction"` + Status string `json:"status"` + ReviewStatus string `json:"review_status"` + ReviewReason string `json:"review_reason"` + PublishedAt *time.Time `json:"published_at"` + ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CreateRequest struct { @@ -66,6 +66,11 @@ type AdminPriceAdjustRequest struct { Reason string `json:"reason"` } +type TransferOwnerRequest struct { + TargetUserID uint64 `json:"target_user_id" binding:"required"` + Reason string `json:"reason"` +} + type AdminListQuery struct { OwnerID uint64 Status string @@ -187,23 +192,25 @@ type ExternalUploadMeta struct { } type ExternalUploadResult struct { - Index int `json:"index"` - ListingID uint64 `json:"listing_id,omitempty"` - ListingNo string `json:"listing_no,omitempty"` - AccountID uint64 `json:"account_id,omitempty"` - Status string `json:"status,omitempty"` - ReviewStatus string `json:"review_status,omitempty"` - Error string `json:"error,omitempty"` + Index int `json:"index"` + ListingID uint64 `json:"listing_id,omitempty"` + ListingNo string `json:"listing_no,omitempty"` + AccountID uint64 `json:"account_id,omitempty"` + ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"` + Status string `json:"status,omitempty"` + ReviewStatus string `json:"review_status,omitempty"` + Error string `json:"error,omitempty"` } type ExternalUploadResponse struct { - ListingID uint64 `json:"listing_id,omitempty"` - ListingNo string `json:"listing_no,omitempty"` - AccountID uint64 `json:"account_id,omitempty"` - Status string `json:"status,omitempty"` - ReviewStatus string `json:"review_status,omitempty"` - Total int `json:"total"` - Success int `json:"success"` - Failed int `json:"failed"` - Items []ExternalUploadResult `json:"items,omitempty"` + ListingID uint64 `json:"listing_id,omitempty"` + ListingNo string `json:"listing_no,omitempty"` + AccountID uint64 `json:"account_id,omitempty"` + ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"` + Status string `json:"status,omitempty"` + ReviewStatus string `json:"review_status,omitempty"` + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + Items []ExternalUploadResult `json:"items,omitempty"` } diff --git a/backend/internal/modules/listing/external_upload.go b/backend/internal/modules/listing/external_upload.go index 48f5bac..e2a4eb1 100644 --- a/backend/internal/modules/listing/external_upload.go +++ b/backend/internal/modules/listing/external_upload.go @@ -60,18 +60,20 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe } resp.Success++ result := ExternalUploadResult{ - Index: index, - ListingID: dto.ID, - ListingNo: dto.ListingNo, - AccountID: dto.AccountID, - Status: dto.Status, - ReviewStatus: dto.ReviewStatus, + Index: index, + ListingID: dto.ID, + ListingNo: dto.ListingNo, + AccountID: dto.AccountID, + ListingGroupConversationID: dto.ListingGroupConversationID, + Status: dto.Status, + ReviewStatus: dto.ReviewStatus, } results = append(results, result) if len(items) == 1 { resp.ListingID = dto.ID resp.ListingNo = dto.ListingNo resp.AccountID = dto.AccountID + resp.ListingGroupConversationID = dto.ListingGroupConversationID resp.Status = dto.Status resp.ReviewStatus = dto.ReviewStatus } diff --git a/backend/internal/modules/listing/handler_admin.go b/backend/internal/modules/listing/handler_admin.go index 03dd6af..0817ed5 100644 --- a/backend/internal/modules/listing/handler_admin.go +++ b/backend/internal/modules/listing/handler_admin.go @@ -52,6 +52,29 @@ func (h *Handler) AdminMarkAbnormal(c *gin.Context) { h.adminAction(c, h.service.AdminMarkAbnormal) } +func (h *Handler) TransferOwner(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req TransferOwnerRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "转移参数不正确") + return + } + item, err := h.service.TransferOwner(c.Request.Context(), adminID, id, req, auditMeta(c)) + if err != nil { + writeListingError(c, err) + return + } + response.OK(c, item) +} + func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) (*ListingDTO, error)) { adminID, ok := currentAdminID(c) if !ok { diff --git a/backend/internal/modules/listing/handler_error.go b/backend/internal/modules/listing/handler_error.go index 0a21338..1ee0e27 100644 --- a/backend/internal/modules/listing/handler_error.go +++ b/backend/internal/modules/listing/handler_error.go @@ -34,6 +34,8 @@ func writeListingError(c *gin.Context, err error) { response.BadRequest(c, "在线时间不正确") case errors.Is(err, ErrAgreementRequired): response.BadRequest(c, "请先阅读并同意发布协议") + case errors.Is(err, ErrTargetOwnerInvalid): + response.BadRequest(c, "目标用户不存在、未实名或状态不可用") case errors.Is(err, ErrMissingUploaderName): response.BadRequest(c, "上传人名称不能为空") case errors.Is(err, ErrMissingUploadData): diff --git a/backend/internal/modules/listing/mutation.go b/backend/internal/modules/listing/mutation.go index 6b4b15e..fa766ee 100644 --- a/backend/internal/modules/listing/mutation.go +++ b/backend/internal/modules/listing/mutation.go @@ -63,7 +63,7 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque // 发布提交时建发布群 var conversationID uint64 if r.chatCreator != nil { - convID, err := r.chatCreator.EnsureListingConversation(tx, listing) + convID, err := r.chatCreator.EnsureListingConversation(tx, listing, 0) if err != nil { return err } @@ -137,6 +137,14 @@ func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload extern if err := tx.Create(&listing).Error; err != nil { return err } + var conversationID uint64 + if r.chatCreator != nil { + convID, err := r.chatCreator.EnsureListingConversation(tx, listing, admin.ID) + if err != nil { + return err + } + conversationID = convID + } matchedAdminID := admin.ID ownerID := owner.ID listingID := listing.ID @@ -155,6 +163,7 @@ func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload extern return err } dto = toDTO(account, listing) + dto.ListingGroupConversationID = conversationID return nil }) return dto, err diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index 44cb330..b71f0fa 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -11,7 +11,7 @@ import ( // ListingChatCreator 发布群创建接口(由 chat 模块实现,避免直接依赖) type ListingChatCreator interface { - EnsureListingConversation(tx *gorm.DB, listing model.RentalListing) (conversationID uint64, err error) + EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, supportAdminID uint64) (conversationID uint64, err error) } type Repository struct { diff --git a/backend/internal/modules/listing/review.go b/backend/internal/modules/listing/review.go index 95c336f..47d4b9a 100644 --- a/backend/internal/modules/listing/review.go +++ b/backend/internal/modules/listing/review.go @@ -6,9 +6,12 @@ import ( "strings" "time" + "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/notification" + "gorm.io/datatypes" "gorm.io/gorm" + "gorm.io/gorm/clause" ) func (r *Repository) ListPendingReview(ctx context.Context) ([]ListingDTO, error) { @@ -32,6 +35,120 @@ func (r *Repository) AdminMarkAbnormal(ctx context.Context, adminID uint64, list return r.adminUpdateStatus(ctx, adminID, listingID, req, meta, "abnormal", "abnormal", "listing.mark_abnormal", "商品已被标记异常", "你的租号商品已被后台标记异常,请联系客服处理。") } +func (r *Repository) TransferOwner(ctx context.Context, adminID uint64, listingID uint64, req TransferOwnerRequest, meta AuditMeta) (*ListingDTO, error) { + var dto *ListingDTO + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + listing, account, err := r.findForReviewUpdate(tx, listingID) + if err != nil { + return err + } + if listing.Status == "rented" || listing.InTransaction { + return ErrListingLocked + } + if listing.OwnerID == req.TargetUserID { + dto = toDTO(*account, *listing) + return nil + } + + var target model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND status = ? AND realname_status = ?", req.TargetUserID, "active", "verified"). + First(&target).Error; err != nil { + if IsNotFound(err) { + return ErrTargetOwnerInvalid + } + return err + } + + beforeOwnerID := listing.OwnerID + listing.OwnerID = target.ID + account.OwnerID = target.ID + if err := tx.Save(account).Error; err != nil { + return err + } + if err := tx.Save(listing).Error; err != nil { + return err + } + if err := tx.Model(&model.ListingUpload{}). + Where("listing_id = ?", listing.ID). + Update("owner_id", target.ID).Error; err != nil { + return err + } + if err := transferListingGroupOwner(tx, listing.ID, beforeOwnerID, target.ID); err != nil { + return err + } + if r.chatCreator != nil { + if _, err := r.chatCreator.EnsureListingConversation(tx, *listing, 0); err != nil { + return err + } + } + + if err := appendAuditLog(tx, adminID, "listing.transfer_owner", "listing", listing.ID, meta, map[string]any{ + "listing_id": listing.ID, + "account_id": account.ID, + "before_owner_id": beforeOwnerID, + "after_owner_id": target.ID, + "reason": req.Reason, + }); err != nil { + return err + } + dto = toDTO(*account, *listing) + dto.OwnerPhone = target.Phone + dto.OwnerNickname = target.Nickname + return nil + }) + return dto, err +} + +func transferListingGroupOwner(tx *gorm.DB, listingID uint64, beforeOwnerID uint64, afterOwnerID uint64) error { + var conversation model.ChatConversation + err := tx.Where("listing_id = ? AND type = ?", listingID, "listing_group").First(&conversation).Error + if err != nil { + if IsNotFound(err) { + return nil + } + return err + } + + if err := tx.Where("conversation_id = ? AND participant_type = ? AND participant_id = ? AND role = ?", + conversation.ID, "user", beforeOwnerID, "owner"). + Delete(&model.ChatParticipant{}).Error; err != nil { + return err + } + participant := model.ChatParticipant{ + ConversationID: conversation.ID, + ParticipantType: "user", + ParticipantID: afterOwnerID, + Role: "owner", + JoinedAt: time.Now(), + } + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil { + return err + } + message := model.ChatMessage{ + ConversationID: conversation.ID, + SenderType: "system", + SenderRole: "system", + ContentType: "system", + Content: "账号归属已由后台转移给新号主", + AttachmentURLS: emptyJSONListForListing(), + } + if err := tx.Create(&message).Error; err != nil { + return err + } + return tx.Model(&model.ChatConversation{}). + Where("id = ?", conversation.ID). + Updates(map[string]interface{}{ + "last_message_id": message.ID, + "last_message_preview": message.Content, + "last_message_at": message.CreatedAt, + }).Error +} + +func emptyJSONListForListing() datatypes.JSON { + return datatypes.JSON([]byte("[]")) +} + func (r *Repository) adminUpdateStatus(ctx context.Context, adminID uint64, listingID uint64, req AdminActionRequest, meta AuditMeta, listingStatus string, accountStatus string, action string, title string, content string) (*ListingDTO, error) { var dto *ListingDTO err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index a8a7614..15ac1ea 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -20,6 +20,7 @@ var ( ErrMissingOnlineTime = errors.New("missing online time") ErrInvalidOnlineTime = errors.New("invalid online time") ErrAgreementRequired = errors.New("listing publish agreement required") + ErrTargetOwnerInvalid = errors.New("target owner invalid") ErrMissingUploaderName = errors.New("missing uploader name") ErrMissingUploadData = errors.New("missing upload data") ErrUploaderNotFound = errors.New("uploader not found") diff --git a/backend/internal/modules/listing/service_review.go b/backend/internal/modules/listing/service_review.go index 2c2c654..fe5bb81 100644 --- a/backend/internal/modules/listing/service_review.go +++ b/backend/internal/modules/listing/service_review.go @@ -62,6 +62,16 @@ func (s *Service) AdjustReviewPrice(ctx context.Context, adminID uint64, id uint return s.repo.AdjustReviewPrice(ctx, adminID, id, req, meta) } +func (s *Service) TransferOwner(ctx context.Context, adminID uint64, id uint64, req TransferOwnerRequest, meta AuditMeta) (*ListingDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.TargetUserID == 0 || req.Reason == "" { + return nil, ErrInvalidInput + } + return s.repo.TransferOwner(ctx, adminID, id, req, meta) +} + func (s *Service) Reject(ctx context.Context, id uint64, req ReviewRequest) (*ListingDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d6586af..c86000b 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -501,6 +501,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin) adminRoutes.POST("/listings/:id/approve", requirePerm("listing:approve"), listingHandler.Approve) adminRoutes.POST("/listings/:id/adjust-price", requirePerm("listing:approve"), listingHandler.AdjustReviewPrice) + adminRoutes.POST("/listings/:id/transfer-owner", requirePerm("listing:approve"), listingHandler.TransferOwner) adminRoutes.POST("/listings/:id/reject", requirePerm("listing:reject"), listingHandler.Reject) adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline) adminRoutes.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal) diff --git a/frontend/src/features/admin/views/AdminListingDetailView.vue b/frontend/src/features/admin/views/AdminListingDetailView.vue index d777468..9610ef7 100644 --- a/frontend/src/features/admin/views/AdminListingDetailView.vue +++ b/frontend/src/features/admin/views/AdminListingDetailView.vue @@ -1,29 +1,42 @@