支持后台转移发布所有权
This commit is contained in:
@@ -15,8 +15,8 @@ func NewListingChatCreatorAdapter() *ListingChatCreatorAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EnsureListingConversation 实现接口
|
// EnsureListingConversation 实现接口
|
||||||
func (a *ListingChatCreatorAdapter) EnsureListingConversation(tx *gorm.DB, listing model.RentalListing) (uint64, error) {
|
func (a *ListingChatCreatorAdapter) EnsureListingConversation(tx *gorm.DB, listing model.RentalListing, supportAdminID uint64) (uint64, error) {
|
||||||
conv, err := EnsureListingConversation(tx, listing)
|
conv, err := EnsureListingConversation(tx, listing, supportAdminID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// EnsureListingConversation 确保发布群存在(幂等)
|
// 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. 幂等查重
|
// 1. 幂等查重
|
||||||
var existing model.ChatConversation
|
var existing model.ChatConversation
|
||||||
err := tx.Where("listing_id = ?", listing.ID).First(&existing).Error
|
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{
|
participants = append(participants, model.ChatParticipant{
|
||||||
ConversationID: conversation.ID,
|
ConversationID: conversation.ID,
|
||||||
ParticipantType: "admin",
|
ParticipantType: "admin",
|
||||||
|
|||||||
@@ -9,33 +9,33 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ListingDTO struct {
|
type ListingDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
ListingNo string `json:"listing_no"`
|
ListingNo string `json:"listing_no"`
|
||||||
AccountID uint64 `json:"account_id"`
|
AccountID uint64 `json:"account_id"`
|
||||||
OwnerID uint64 `json:"owner_id"`
|
OwnerID uint64 `json:"owner_id"`
|
||||||
OwnerPhone string `json:"owner_phone,omitempty"`
|
OwnerPhone string `json:"owner_phone,omitempty"`
|
||||||
OwnerNickname string `json:"owner_nickname,omitempty"`
|
OwnerNickname string `json:"owner_nickname,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
GameName string `json:"game_name"`
|
GameName string `json:"game_name"`
|
||||||
ServerRegion string `json:"server_region"`
|
ServerRegion string `json:"server_region"`
|
||||||
LoginPlatform string `json:"login_platform"`
|
LoginPlatform string `json:"login_platform"`
|
||||||
RankLevel string `json:"rank_level"`
|
RankLevel string `json:"rank_level"`
|
||||||
HafCoinAmount int64 `json:"haf_coin_amount"`
|
HafCoinAmount int64 `json:"haf_coin_amount"`
|
||||||
AssetSummary map[string]any `json:"asset_summary,omitempty"`
|
AssetSummary map[string]any `json:"asset_summary,omitempty"`
|
||||||
ScreenshotURLS []string `json:"screenshot_urls"`
|
ScreenshotURLS []string `json:"screenshot_urls"`
|
||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
PriceCent int64 `json:"price_cent"`
|
PriceCent int64 `json:"price_cent"`
|
||||||
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
DepositAmountCent int64 `json:"deposit_amount_cent"`
|
||||||
IsAccelerated bool `json:"is_accelerated_sale"`
|
IsAccelerated bool `json:"is_accelerated_sale"`
|
||||||
InTransaction bool `json:"in_transaction"`
|
InTransaction bool `json:"in_transaction"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ReviewStatus string `json:"review_status"`
|
ReviewStatus string `json:"review_status"`
|
||||||
ReviewReason string `json:"review_reason"`
|
ReviewReason string `json:"review_reason"`
|
||||||
PublishedAt *time.Time `json:"published_at"`
|
PublishedAt *time.Time `json:"published_at"`
|
||||||
ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"`
|
ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateRequest struct {
|
type CreateRequest struct {
|
||||||
@@ -66,6 +66,11 @@ type AdminPriceAdjustRequest struct {
|
|||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TransferOwnerRequest struct {
|
||||||
|
TargetUserID uint64 `json:"target_user_id" binding:"required"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
type AdminListQuery struct {
|
type AdminListQuery struct {
|
||||||
OwnerID uint64
|
OwnerID uint64
|
||||||
Status string
|
Status string
|
||||||
@@ -187,23 +192,25 @@ type ExternalUploadMeta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ExternalUploadResult struct {
|
type ExternalUploadResult struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
ListingID uint64 `json:"listing_id,omitempty"`
|
ListingID uint64 `json:"listing_id,omitempty"`
|
||||||
ListingNo string `json:"listing_no,omitempty"`
|
ListingNo string `json:"listing_no,omitempty"`
|
||||||
AccountID uint64 `json:"account_id,omitempty"`
|
AccountID uint64 `json:"account_id,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"`
|
||||||
ReviewStatus string `json:"review_status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
ReviewStatus string `json:"review_status,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExternalUploadResponse struct {
|
type ExternalUploadResponse struct {
|
||||||
ListingID uint64 `json:"listing_id,omitempty"`
|
ListingID uint64 `json:"listing_id,omitempty"`
|
||||||
ListingNo string `json:"listing_no,omitempty"`
|
ListingNo string `json:"listing_no,omitempty"`
|
||||||
AccountID uint64 `json:"account_id,omitempty"`
|
AccountID uint64 `json:"account_id,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
ListingGroupConversationID uint64 `json:"listing_group_conversation_id,omitempty"`
|
||||||
ReviewStatus string `json:"review_status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Total int `json:"total"`
|
ReviewStatus string `json:"review_status,omitempty"`
|
||||||
Success int `json:"success"`
|
Total int `json:"total"`
|
||||||
Failed int `json:"failed"`
|
Success int `json:"success"`
|
||||||
Items []ExternalUploadResult `json:"items,omitempty"`
|
Failed int `json:"failed"`
|
||||||
|
Items []ExternalUploadResult `json:"items,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,18 +60,20 @@ func (s *Service) ImportExternalUpload(ctx context.Context, req ExternalUploadRe
|
|||||||
}
|
}
|
||||||
resp.Success++
|
resp.Success++
|
||||||
result := ExternalUploadResult{
|
result := ExternalUploadResult{
|
||||||
Index: index,
|
Index: index,
|
||||||
ListingID: dto.ID,
|
ListingID: dto.ID,
|
||||||
ListingNo: dto.ListingNo,
|
ListingNo: dto.ListingNo,
|
||||||
AccountID: dto.AccountID,
|
AccountID: dto.AccountID,
|
||||||
Status: dto.Status,
|
ListingGroupConversationID: dto.ListingGroupConversationID,
|
||||||
ReviewStatus: dto.ReviewStatus,
|
Status: dto.Status,
|
||||||
|
ReviewStatus: dto.ReviewStatus,
|
||||||
}
|
}
|
||||||
results = append(results, result)
|
results = append(results, result)
|
||||||
if len(items) == 1 {
|
if len(items) == 1 {
|
||||||
resp.ListingID = dto.ID
|
resp.ListingID = dto.ID
|
||||||
resp.ListingNo = dto.ListingNo
|
resp.ListingNo = dto.ListingNo
|
||||||
resp.AccountID = dto.AccountID
|
resp.AccountID = dto.AccountID
|
||||||
|
resp.ListingGroupConversationID = dto.ListingGroupConversationID
|
||||||
resp.Status = dto.Status
|
resp.Status = dto.Status
|
||||||
resp.ReviewStatus = dto.ReviewStatus
|
resp.ReviewStatus = dto.ReviewStatus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,29 @@ func (h *Handler) AdminMarkAbnormal(c *gin.Context) {
|
|||||||
h.adminAction(c, h.service.AdminMarkAbnormal)
|
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)) {
|
func (h *Handler) adminAction(c *gin.Context, fn func(context.Context, uint64, uint64, AdminActionRequest, AuditMeta) (*ListingDTO, error)) {
|
||||||
adminID, ok := currentAdminID(c)
|
adminID, ok := currentAdminID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ func writeListingError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "在线时间不正确")
|
response.BadRequest(c, "在线时间不正确")
|
||||||
case errors.Is(err, ErrAgreementRequired):
|
case errors.Is(err, ErrAgreementRequired):
|
||||||
response.BadRequest(c, "请先阅读并同意发布协议")
|
response.BadRequest(c, "请先阅读并同意发布协议")
|
||||||
|
case errors.Is(err, ErrTargetOwnerInvalid):
|
||||||
|
response.BadRequest(c, "目标用户不存在、未实名或状态不可用")
|
||||||
case errors.Is(err, ErrMissingUploaderName):
|
case errors.Is(err, ErrMissingUploaderName):
|
||||||
response.BadRequest(c, "上传人名称不能为空")
|
response.BadRequest(c, "上传人名称不能为空")
|
||||||
case errors.Is(err, ErrMissingUploadData):
|
case errors.Is(err, ErrMissingUploadData):
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (r *Repository) Create(ctx context.Context, ownerID uint64, req CreateReque
|
|||||||
// 发布提交时建发布群
|
// 发布提交时建发布群
|
||||||
var conversationID uint64
|
var conversationID uint64
|
||||||
if r.chatCreator != nil {
|
if r.chatCreator != nil {
|
||||||
convID, err := r.chatCreator.EnsureListingConversation(tx, listing)
|
convID, err := r.chatCreator.EnsureListingConversation(tx, listing, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -137,6 +137,14 @@ func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload extern
|
|||||||
if err := tx.Create(&listing).Error; err != nil {
|
if err := tx.Create(&listing).Error; err != nil {
|
||||||
return err
|
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
|
matchedAdminID := admin.ID
|
||||||
ownerID := owner.ID
|
ownerID := owner.ID
|
||||||
listingID := listing.ID
|
listingID := listing.ID
|
||||||
@@ -155,6 +163,7 @@ func (r *Repository) CreateFromExternalUpload(ctx context.Context, upload extern
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dto = toDTO(account, listing)
|
dto = toDTO(account, listing)
|
||||||
|
dto.ListingGroupConversationID = conversationID
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
return dto, err
|
return dto, err
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
// ListingChatCreator 发布群创建接口(由 chat 模块实现,避免直接依赖)
|
// ListingChatCreator 发布群创建接口(由 chat 模块实现,避免直接依赖)
|
||||||
type ListingChatCreator interface {
|
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 {
|
type Repository struct {
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/notification"
|
"hfb_sys/backend/internal/modules/notification"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *Repository) ListPendingReview(ctx context.Context) ([]ListingDTO, error) {
|
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", "商品已被标记异常", "你的租号商品已被后台标记异常,请联系客服处理。")
|
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) {
|
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
|
var dto *ListingDTO
|
||||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ var (
|
|||||||
ErrMissingOnlineTime = errors.New("missing online time")
|
ErrMissingOnlineTime = errors.New("missing online time")
|
||||||
ErrInvalidOnlineTime = errors.New("invalid online time")
|
ErrInvalidOnlineTime = errors.New("invalid online time")
|
||||||
ErrAgreementRequired = errors.New("listing publish agreement required")
|
ErrAgreementRequired = errors.New("listing publish agreement required")
|
||||||
|
ErrTargetOwnerInvalid = errors.New("target owner invalid")
|
||||||
ErrMissingUploaderName = errors.New("missing uploader name")
|
ErrMissingUploaderName = errors.New("missing uploader name")
|
||||||
ErrMissingUploadData = errors.New("missing upload data")
|
ErrMissingUploadData = errors.New("missing upload data")
|
||||||
ErrUploaderNotFound = errors.New("uploader not found")
|
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)
|
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) {
|
func (s *Service) Reject(ctx context.Context, id uint64, req ReviewRequest) (*ListingDTO, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
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.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin)
|
||||||
adminRoutes.POST("/listings/:id/approve", requirePerm("listing:approve"), listingHandler.Approve)
|
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/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/reject", requirePerm("listing:reject"), listingHandler.Reject)
|
||||||
adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline)
|
adminRoutes.POST("/listings/:id/offline", requirePerm("listing:offline"), listingHandler.AdminOffline)
|
||||||
adminRoutes.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal)
|
adminRoutes.POST("/listings/:id/mark-abnormal", requirePerm("listing:offline"), listingHandler.AdminMarkAbnormal)
|
||||||
|
|||||||
@@ -1,29 +1,42 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { readError } from '@/shared/utils/error'
|
import { readError } from '@/shared/utils/error'
|
||||||
import { CopyDocument } from '@element-plus/icons-vue'
|
import { CopyDocument, Search } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { fetchAdminUsers, type AdminUserItem } from '@/features/admin/api/adminUsers'
|
||||||
import { fetchAdminFileBlob } from '@/shared/api/files'
|
import { fetchAdminFileBlob } from '@/shared/api/files'
|
||||||
import { adminPath } from '@/shared/utils/adminPath'
|
import { adminPath } from '@/shared/utils/adminPath'
|
||||||
import { formatCentWithSymbol } from '@/shared/utils/money'
|
import { formatCentWithSymbol } from '@/shared/utils/money'
|
||||||
import {
|
import {
|
||||||
adminMarkListingAbnormal,
|
adminMarkListingAbnormal,
|
||||||
adminOfflineListing,
|
adminOfflineListing,
|
||||||
|
adminTransferListingOwner,
|
||||||
fetchAdminListing,
|
fetchAdminListing,
|
||||||
type Listing,
|
type Listing,
|
||||||
} from '@/features/listings'
|
} from '@/features/listings'
|
||||||
import { listingReviewStatusLabel, listingStatusLabel } from '@/shared/utils/statusLabels'
|
import {
|
||||||
|
listingReviewStatusLabel,
|
||||||
|
listingStatusLabel,
|
||||||
|
realnameStatusLabel,
|
||||||
|
userStatusLabel,
|
||||||
|
} from '@/shared/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
import { formatListingCode } from '@/shared/utils/listingDisplay'
|
import { formatListingCode } from '@/shared/utils/listingDisplay'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const userSearching = ref(false)
|
||||||
const listing = ref<Listing | null>(null)
|
const listing = ref<Listing | null>(null)
|
||||||
const actionType = ref<'offline' | 'abnormal' | ''>('')
|
const actionType = ref<'offline' | 'abnormal' | ''>('')
|
||||||
const reason = ref('')
|
const reason = ref('')
|
||||||
|
const transferVisible = ref(false)
|
||||||
|
const transferReason = ref('')
|
||||||
|
const userKeyword = ref('')
|
||||||
|
const userOptions = ref<AdminUserItem[]>([])
|
||||||
|
const selectedUser = ref<AdminUserItem | null>(null)
|
||||||
|
|
||||||
const actionTitle = computed(() =>
|
const actionTitle = computed(() =>
|
||||||
actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'
|
actionType.value === 'offline' ? '强制下架商品' : '标记商品异常'
|
||||||
@@ -34,6 +47,10 @@ const canOperate = computed(
|
|||||||
listing.value.status !== 'rented' &&
|
listing.value.status !== 'rented' &&
|
||||||
!['offline', 'abnormal'].includes(listing.value.status)
|
!['offline', 'abnormal'].includes(listing.value.status)
|
||||||
)
|
)
|
||||||
|
const canTransfer = computed(() => !!listing.value && listing.value.status !== 'rented')
|
||||||
|
const selectedUserAvailable = computed(
|
||||||
|
() => selectedUser.value?.status === 'active' && selectedUser.value.realname_status === 'verified'
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(loadListing)
|
onMounted(loadListing)
|
||||||
|
|
||||||
@@ -61,6 +78,33 @@ function openAction(type: 'offline' | 'abnormal') {
|
|||||||
reason.value = ''
|
reason.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTransfer() {
|
||||||
|
transferVisible.value = true
|
||||||
|
transferReason.value = ''
|
||||||
|
userKeyword.value = ''
|
||||||
|
selectedUser.value = null
|
||||||
|
userOptions.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchUsers() {
|
||||||
|
userSearching.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchAdminUsers(1, 8, {
|
||||||
|
keyword: userKeyword.value.trim(),
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
userOptions.value = result.items.filter(user => user.realname_status === 'verified')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '查询用户失败'))
|
||||||
|
} finally {
|
||||||
|
userSearching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUser(user: AdminUserItem) {
|
||||||
|
selectedUser.value = user
|
||||||
|
}
|
||||||
|
|
||||||
async function submitAction() {
|
async function submitAction() {
|
||||||
if (!listing.value || !actionType.value) return
|
if (!listing.value || !actionType.value) return
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
@@ -80,6 +124,32 @@ async function submitAction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitTransferOwner() {
|
||||||
|
if (!listing.value || !selectedUser.value) return
|
||||||
|
if (!selectedUserAvailable.value) {
|
||||||
|
ElMessage.warning('只能转移给正常且已实名的用户')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!transferReason.value.trim()) {
|
||||||
|
ElMessage.warning('请填写转移原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
listing.value = await adminTransferListingOwner(
|
||||||
|
listing.value.id,
|
||||||
|
selectedUser.value.id,
|
||||||
|
transferReason.value.trim()
|
||||||
|
)
|
||||||
|
ElMessage.success('所有权已转移')
|
||||||
|
transferVisible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(readError(error, '转移失败'))
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function moneyCent(value: number) {
|
function moneyCent(value: number) {
|
||||||
return formatCentWithSymbol(value)
|
return formatCentWithSymbol(value)
|
||||||
}
|
}
|
||||||
@@ -120,6 +190,9 @@ async function openScreenshot(url: string) {
|
|||||||
<RouterLink :to="adminPath('listings')">
|
<RouterLink :to="adminPath('listings')">
|
||||||
<el-button>返回列表</el-button>
|
<el-button>返回列表</el-button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<el-button type="primary" plain :disabled="!canTransfer" @click="openTransfer"
|
||||||
|
>转移所有权</el-button
|
||||||
|
>
|
||||||
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')"
|
<el-button type="warning" :disabled="!canOperate" @click="openAction('offline')"
|
||||||
>强制下架</el-button
|
>强制下架</el-button
|
||||||
>
|
>
|
||||||
@@ -211,5 +284,147 @@ async function openScreenshot(url: string) {
|
|||||||
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
|
<el-button type="danger" :loading="submitting" @click="submitAction">确认操作</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="transferVisible" title="转移发布所有权" width="640px">
|
||||||
|
<div v-if="listing" class="dialog-body transfer-dialog">
|
||||||
|
<div class="transfer-current">
|
||||||
|
<span>当前号主</span>
|
||||||
|
<strong>{{ listing.owner_phone || listing.owner_nickname || listing.owner_id }}</strong>
|
||||||
|
<small>ID:{{ listing.owner_id }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-input
|
||||||
|
v-model="userKeyword"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索手机号 / 昵称 / 用户ID"
|
||||||
|
@keyup.enter="searchUsers"
|
||||||
|
@clear="searchUsers"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<el-button :icon="Search" :loading="userSearching" @click="searchUsers">查询</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
|
||||||
|
<div class="transfer-user-list">
|
||||||
|
<button
|
||||||
|
v-for="user in userOptions"
|
||||||
|
:key="user.id"
|
||||||
|
class="transfer-user"
|
||||||
|
:class="{ 'is-selected': selectedUser?.id === user.id }"
|
||||||
|
type="button"
|
||||||
|
@click="selectUser(user)"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>{{ user.nickname || user.phone || `用户${user.id}` }}</strong>
|
||||||
|
<small>{{ user.phone || '-' }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="transfer-user-meta">
|
||||||
|
<el-tag size="small" type="success">{{ userStatusLabel(user.status) }}</el-tag>
|
||||||
|
<el-tag size="small">{{ realnameStatusLabel(user.realname_status) }}</el-tag>
|
||||||
|
<small>ID:{{ user.id }}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<el-empty
|
||||||
|
v-if="!userOptions.length"
|
||||||
|
description="输入关键词后查询已实名用户"
|
||||||
|
:image-size="72"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-input
|
||||||
|
v-model="transferReason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="填写转移原因,会写入审计日志"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="transferVisible = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!selectedUser || !selectedUserAvailable"
|
||||||
|
@click="submitTransferOwner"
|
||||||
|
>
|
||||||
|
确认转移
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transfer-dialog {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-current {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-current strong {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-current small {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-user-list {
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-user {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-user:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-user:hover,
|
||||||
|
.transfer-user.is-selected {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-user strong {
|
||||||
|
display: block;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-user small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-user-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -218,6 +218,17 @@ export async function adminMarkListingAbnormal(id: number, reason: string) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function adminTransferListingOwner(id: number, targetUserId: number, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<Listing>>(
|
||||||
|
`/admin/listings/${id}/transfer-owner`,
|
||||||
|
{
|
||||||
|
target_user_id: targetUserId,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function approveListing(id: number) {
|
export async function approveListing(id: number) {
|
||||||
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/approve`)
|
const { data } = await apiClient.post<ApiResponse<Listing>>(`/admin/listings/${id}/approve`)
|
||||||
return data.data
|
return data.data
|
||||||
|
|||||||
Reference in New Issue
Block a user