支持后台转移发布所有权
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user