diff --git a/backend/internal/model/pickup.go b/backend/internal/model/pickup.go new file mode 100644 index 0000000..bbbbfd6 --- /dev/null +++ b/backend/internal/model/pickup.go @@ -0,0 +1,27 @@ +package model + +import "time" + +// AdminPickup 管理员线下提号记录。 +// 独立于 rental_orders,不进入正常订单状态机与财务统计口径, +// 完成时通过 wallet.AppendEntries 给卖家增加可用余额。 +type AdminPickup struct { + ID uint64 `gorm:"primaryKey" json:"id"` + PickupNo string `gorm:"size:64;not null;uniqueIndex" json:"pickup_no"` + ListingID uint64 `gorm:"not null;index" json:"listing_id"` + AccountID uint64 `gorm:"not null;index" json:"account_id"` + OwnerID uint64 `gorm:"not null;index" json:"owner_id"` + AdminID uint64 `gorm:"not null" json:"admin_id"` + Platform string `gorm:"size:32;not null;default:''" json:"platform"` + SettleAmountCent int64 `gorm:"not null;default:0" json:"settle_amount_cent"` + Status string `gorm:"size:20;not null;default:'picking_up';index" json:"status"` + Remark string `gorm:"size:255;not null;default:''" json:"remark"` + CompleteRemark string `gorm:"size:255;not null;default:''" json:"complete_remark"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CancelledAt *time.Time `json:"cancelled_at,omitempty"` +} + +func (AdminPickup) TableName() string { + return "admin_pickups" +} diff --git a/backend/internal/modules/adminfinance/dashboard.go b/backend/internal/modules/adminfinance/dashboard.go index 2a92862..c7adc4d 100644 --- a/backend/internal/modules/adminfinance/dashboard.go +++ b/backend/internal/modules/adminfinance/dashboard.go @@ -15,10 +15,40 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash if err != nil { return nil, err } + pickup, err := r.pickupSummary(ctx, query) + if err != nil { + return nil, err + } return &DashboardDTO{ - Summary: *summary, - DailyItems: dailyItems, - GeneratedAt: timeutil.ShanghaiNow(), + Summary: *summary, + DailyItems: dailyItems, + PickupSummary: *pickup, + GeneratedAt: timeutil.ShanghaiNow(), + }, nil +} + +// pickupSummary 线下提号统计,独立查询 admin_pickups 表,不混入正常订单口径。 +func (r *Repository) pickupSummary(ctx context.Context, query DashboardQuery) (*PickupSummaryDTO, error) { + db := r.db.WithContext(ctx) + var row struct { + SettledAmountCent int64 + CompletedCount int64 + } + if err := db.Table("admin_pickups"). + Select(`COALESCE(SUM(settle_amount_cent), 0) AS settled_amount_cent, COUNT(id) AS completed_count`). + Where("status = ?", "completed"). + Where("completed_at >= ? AND completed_at <= ?", query.StartDate, query.EndDate). + Scan(&row).Error; err != nil { + return nil, err + } + var inProgress int64 + if err := db.Table("admin_pickups").Where("status = ?", "picking_up").Count(&inProgress).Error; err != nil { + return nil, err + } + return &PickupSummaryDTO{ + SettledAmountCent: row.SettledAmountCent, + CompletedCount: row.CompletedCount, + InProgressCount: inProgress, }, nil } diff --git a/backend/internal/modules/adminfinance/dto.go b/backend/internal/modules/adminfinance/dto.go index 51f2192..98dce65 100644 --- a/backend/internal/modules/adminfinance/dto.go +++ b/backend/internal/modules/adminfinance/dto.go @@ -20,9 +20,17 @@ type DetailQuery struct { } type DashboardDTO struct { - Summary FinanceSummaryDTO `json:"summary"` - DailyItems []FinanceDailyDTO `json:"daily_items"` - GeneratedAt time.Time `json:"generated_at"` + Summary FinanceSummaryDTO `json:"summary"` + DailyItems []FinanceDailyDTO `json:"daily_items"` + PickupSummary PickupSummaryDTO `json:"pickup_summary"` + GeneratedAt time.Time `json:"generated_at"` +} + +// PickupSummaryDTO 线下提号统计,独立于正常订单口径,数据来自 admin_pickups 表。 +type PickupSummaryDTO struct { + SettledAmountCent int64 `json:"settled_amount_cent"` // 区间内已完成提号的结算金额合计 + CompletedCount int64 `json:"completed_count"` // 区间内已完成提号笔数 + InProgressCount int64 `json:"in_progress_count"` // 当前提号中笔数(不按时间) } type FinanceSummaryDTO struct { diff --git a/backend/internal/modules/pickup/dto.go b/backend/internal/modules/pickup/dto.go new file mode 100644 index 0000000..dce3851 --- /dev/null +++ b/backend/internal/modules/pickup/dto.go @@ -0,0 +1,97 @@ +package pickup + +import ( + "errors" + "time" +) + +// 提号状态 +const ( + StatusPickingUp = "picking_up" + StatusCompleted = "completed" + StatusCancelled = "cancelled" +) + +var ( + ErrDependencyUnavailable = errors.New("dependency unavailable") + ErrListingUnavailable = errors.New("listing unavailable") + ErrPickupNotFound = errors.New("pickup not found") + ErrPickupNotPickingUp = errors.New("pickup not in picking_up status") + ErrInvalidAmount = errors.New("invalid amount") + ErrDuplicatePickup = errors.New("duplicate pickup in progress") +) + +type PickupDTO struct { + ID uint64 `json:"id"` + PickupNo string `json:"pickup_no"` + ListingID uint64 `json:"listing_id"` + ListingNo string `json:"listing_no"` + AccountID uint64 `json:"account_id"` + AccountTitle string `json:"account_title"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + OwnerID uint64 `json:"owner_id"` + OwnerPhone string `json:"owner_phone"` + AdminID uint64 `json:"admin_id"` + Platform string `json:"platform"` + SettleAmountCent int64 `json:"settle_amount_cent"` + Status string `json:"status"` + Remark string `json:"remark"` + CompleteRemark string `json:"complete_remark"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CancelledAt *time.Time `json:"cancelled_at,omitempty"` +} + +type CreateRequest struct { + ListingID uint64 `json:"listing_id" binding:"required"` + Platform string `json:"platform"` + Remark string `json:"remark"` +} + +type CompleteRequest struct { + SettleAmountCent int64 `json:"settle_amount_cent" binding:"required,min=1"` + CompleteRemark string `json:"complete_remark"` +} + +type CancelRequest struct { + Reason string `json:"reason" binding:"required"` +} + +type AdminPickupQuery struct { + Page int + PageSize int + Keyword string + Status string +} + +type SellerPickupQuery struct { + Page int + PageSize int + Status string +} + +type AvailableListingDTO struct { + ID uint64 `json:"id"` + ListingNo string `json:"listing_no"` + AccountID uint64 `json:"account_id"` + AccountTitle string `json:"account_title"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + OwnerID uint64 `json:"owner_id"` + OwnerPhone string `json:"owner_phone"` +} + +type PaginatedResult struct { + Items []PickupDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type AvailableListingsResult struct { + Items []AvailableListingDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/backend/internal/modules/pickup/handler.go b/backend/internal/modules/pickup/handler.go new file mode 100644 index 0000000..2d8c38b --- /dev/null +++ b/backend/internal/modules/pickup/handler.go @@ -0,0 +1,168 @@ +package pickup + +import ( + "errors" + "net/http" + "strings" + + "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// Create 创建提号订单(管理员) +func (h *Handler) Create(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + var req CreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数格式错误") + return + } + item, err := h.service.Create(c.Request.Context(), req, adminID, auditMeta(c)) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, item) +} + +// Complete 完成提号结算(管理员) +func (h *Handler) Complete(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req CompleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "结算金额不正确") + return + } + item, err := h.service.Complete(c.Request.Context(), id, req, adminID, auditMeta(c)) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, item) +} + +// Cancel 取消提号(管理员) +func (h *Handler) Cancel(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req CancelRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "取消原因不能为空") + return + } + if err := h.service.Cancel(c.Request.Context(), id, req.Reason, adminID, auditMeta(c)); err != nil { + writePickupError(c, err) + return + } + response.OK(c, gin.H{"cancelled": true}) +} + +// List 提号订单列表(管理员) +func (h *Handler) List(c *gin.Context) { + query := parseAdminQuery(c) + result, err := h.service.ListAdmin(c.Request.Context(), query) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, result) +} + +// Detail 提号订单详情(管理员) +func (h *Handler) Detail(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + item, err := h.service.FindByID(c.Request.Context(), id) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, item) +} + +// AvailableListings 可提号的 listing 搜索(管理员) +func (h *Handler) AvailableListings(c *gin.Context) { + keyword := strings.TrimSpace(c.Query("keyword")) + page, pageSize := parsePagination(c) + result, err := h.service.ListAvailableListings(c.Request.Context(), keyword, page, pageSize) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, result) +} + +// ListForSeller 卖家自己的提号记录 +func (h *Handler) ListForSeller(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + query := parseSellerQuery(c) + result, err := h.service.ListForSeller(c.Request.Context(), userID, query) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, result) +} + +func writePickupError(c *gin.Context, err error) { + switch { + case errors.Is(err, ErrDependencyUnavailable): + response.ServiceUnavailable(c, "数据库未连接") + case errors.Is(err, ErrListingUnavailable): + response.BadRequest(c, "账号不可用或已在交易中") + case errors.Is(err, ErrDuplicatePickup): + response.BadRequest(c, "该账号已有进行中的提号订单") + case errors.Is(err, ErrPickupNotFound): + response.Error(c, http.StatusNotFound, "pickup_not_found", "提号订单不存在") + case errors.Is(err, ErrPickupNotPickingUp): + response.BadRequest(c, "提号订单状态不允许该操作") + case errors.Is(err, ErrInvalidAmount): + response.BadRequest(c, "结算金额不正确") + default: + response.Error(c, http.StatusInternalServerError, "pickup_error", err.Error()) + } +} + +func auditMeta(c *gin.Context) auditlog.Meta { + return auditlog.Meta{ + IP: c.ClientIP(), + UserAgent: c.GetHeader("User-Agent"), + RequestID: middleware.GetRequestID(c), + } +} diff --git a/backend/internal/modules/pickup/handler_parse.go b/backend/internal/modules/pickup/handler_parse.go new file mode 100644 index 0000000..a9ed41a --- /dev/null +++ b/backend/internal/modules/pickup/handler_parse.go @@ -0,0 +1,71 @@ +package pickup + +import ( + "strconv" + + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/pkg/response" + + "github.com/gin-gonic/gin" +) + +func currentAdminID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextAdminID) + if !ok { + return 0, false + } + adminID, ok := value.(uint64) + return adminID, ok +} + +func currentUserID(c *gin.Context) (uint64, bool) { + value, ok := c.Get(middleware.ContextUserID) + if !ok { + return 0, false + } + userID, ok := value.(uint64) + return userID, ok +} + +func parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil || id == 0 { + response.BadRequest(c, "ID 不正确") + return 0, false + } + return id, true +} + +func parsePagination(c *gin.Context) (int, int) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +func parseAdminQuery(c *gin.Context) AdminPickupQuery { + page, pageSize := parsePagination(c) + return AdminPickupQuery{ + Page: page, + PageSize: pageSize, + Keyword: c.Query("keyword"), + Status: c.Query("status"), + } +} + +func parseSellerQuery(c *gin.Context) SellerPickupQuery { + page, pageSize := parsePagination(c) + return SellerPickupQuery{ + Page: page, + PageSize: pageSize, + Status: c.Query("status"), + } +} diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go new file mode 100644 index 0000000..251cd09 --- /dev/null +++ b/backend/internal/modules/pickup/repository.go @@ -0,0 +1,415 @@ +package pickup + +import ( + "context" + crand "crypto/rand" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/model" + "hfb_sys/backend/internal/modules/notification" + "hfb_sys/backend/internal/modules/wallet" + "hfb_sys/backend/internal/timeutil" + "hfb_sys/backend/pkg/money" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +// Create 创建提号订单(状态:提号中)。 +// 锁定 listing.in_transaction,与正常订单互斥;不结算、不动钱包。 +func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + var createdID uint64 + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var listing model.RentalListing + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, req.ListingID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrListingUnavailable + } + return err + } + if listing.Status != "published" || listing.ReviewStatus != "approved" || listing.InTransaction { + return ErrListingUnavailable + } + + // 同一 listing 不能有进行中的提号 + var cnt int64 + if err := tx.Model(&model.AdminPickup{}). + Where("listing_id = ? AND status = ?", listing.ID, StatusPickingUp). + Count(&cnt).Error; err != nil { + return err + } + if cnt > 0 { + return ErrDuplicatePickup + } + + var account model.GameAccount + if err := tx.First(&account, listing.AccountID).Error; err != nil { + return err + } + + pickupNo, err := newPickupNo() + if err != nil { + return err + } + pickup := model.AdminPickup{ + PickupNo: pickupNo, + ListingID: listing.ID, + AccountID: listing.AccountID, + OwnerID: listing.OwnerID, + AdminID: adminID, + Platform: strings.TrimSpace(req.Platform), + Status: StatusPickingUp, + Remark: strings.TrimSpace(req.Remark), + } + if err := tx.Create(&pickup).Error; err != nil { + return err + } + createdID = pickup.ID + + listing.InTransaction = true + if err := tx.Save(&listing).Error; err != nil { + return err + } + + bid := pickup.ID + if err := notification.Append(tx, notification.Entry{ + UserID: listing.OwnerID, + Type: "pickup", + Title: "账号被管理员提号", + Content: fmt.Sprintf("您的账号「%s」已被管理员提号(线下交易),等待完成结算。", account.Title), + BizType: "pickup", + BizID: &bid, + }); err != nil { + return err + } + + if err := auditlog.Append(tx, auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "pickup_create", + BizType: "admin_pickup", + BizID: &bid, + Meta: meta, + Detail: map[string]any{ + "pickup_no": pickupNo, + "listing_id": listing.ID, + "platform": pickup.Platform, + }, + }); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + return r.FindByID(ctx, createdID) +} + +// Complete 完成提号:给卖家加可用余额,listing 置 completed(售出终态,不可再上架)。 +func (r *Repository) Complete(ctx context.Context, pickupID uint64, req CompleteRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + if req.SettleAmountCent <= 0 { + return nil, ErrInvalidAmount + } + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var pickup model.AdminPickup + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pickup, pickupID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrPickupNotFound + } + return err + } + if pickup.Status != StatusPickingUp { + return ErrPickupNotPickingUp + } + + var listing model.RentalListing + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, pickup.ListingID).Error; err != nil { + return err + } + var account model.GameAccount + _ = tx.First(&account, pickup.AccountID).Error + + now := time.Now() + pickup.SettleAmountCent = req.SettleAmountCent + pickup.Status = StatusCompleted + pickup.CompleteRemark = strings.TrimSpace(req.CompleteRemark) + pickup.CompletedAt = &now + if err := tx.Save(&pickup).Error; err != nil { + return err + } + + // listing 置 completed:listingLockedForOwnerMutation 已拦截此状态再上架。 + listing.InTransaction = true + listing.Status = "completed" + if err := tx.Save(&listing).Error; err != nil { + return err + } + + if err := wallet.AppendEntries(tx, wallet.Entry{ + UserID: pickup.OwnerID, + Direction: "in", + AmountCent: req.SettleAmountCent, + BalanceType: "available", + BizType: "admin_pickup_settle", + BizNo: pickup.PickupNo, + Remark: fmt.Sprintf("管理员提号结算:%s", pickup.PickupNo), + }); err != nil { + return err + } + + bid := pickup.ID + if err := notification.Append(tx, notification.Entry{ + UserID: pickup.OwnerID, + Type: "pickup", + Title: "提号已完成,已结算到账", + Content: fmt.Sprintf("账号「%s」提号完成,已结算 %s 到您的钱包。", account.Title, money.FormatWithSymbol(req.SettleAmountCent)), + BizType: "pickup", + BizID: &bid, + }); err != nil { + return err + } + + if err := auditlog.Append(tx, auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "pickup_complete", + BizType: "admin_pickup", + BizID: &bid, + Meta: meta, + Detail: map[string]any{ + "pickup_no": pickup.PickupNo, + "settle_amount_cent": req.SettleAmountCent, + }, + }); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + return r.FindByID(ctx, pickupID) +} + +// Cancel 取消提号:解锁 listing.in_transaction,账号恢复可租。 +func (r *Repository) Cancel(ctx context.Context, pickupID uint64, reason string, adminID uint64, meta auditlog.Meta) error { + if r == nil || r.db == nil { + return ErrDependencyUnavailable + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var pickup model.AdminPickup + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pickup, pickupID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrPickupNotFound + } + return err + } + if pickup.Status != StatusPickingUp { + return ErrPickupNotPickingUp + } + var listing model.RentalListing + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&listing, pickup.ListingID).Error; err != nil { + return err + } + now := time.Now() + pickup.Status = StatusCancelled + pickup.CancelledAt = &now + if err := tx.Save(&pickup).Error; err != nil { + return err + } + listing.InTransaction = false + if err := tx.Save(&listing).Error; err != nil { + return err + } + bid := pickup.ID + _ = notification.Append(tx, notification.Entry{ + UserID: pickup.OwnerID, + Type: "pickup", + Title: "提号已取消", + Content: fmt.Sprintf("提号订单 %s 已取消,原因:%s", pickup.PickupNo, reason), + BizType: "pickup", + BizID: &bid, + }) + _ = auditlog.Append(tx, auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "pickup_cancel", + BizType: "admin_pickup", + BizID: &bid, + Meta: meta, + Detail: map[string]any{"pickup_no": pickup.PickupNo, "reason": reason}, + }) + return nil + }) +} + +func (r *Repository) FindByID(ctx context.Context, id uint64) (*PickupDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + var row pickupRow + if err := r.baseQuery(ctx).Where("p.id = ?", id).First(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrPickupNotFound + } + return nil, err + } + dto := row.toDTO() + return &dto, nil +} + +func (r *Repository) ListAdmin(ctx context.Context, query AdminPickupQuery) (*PaginatedResult, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + db := r.baseQuery(ctx) + if status := strings.TrimSpace(query.Status); status != "" { + db = db.Where("p.status = ?", status) + } + if kw := strings.TrimSpace(query.Keyword); kw != "" { + like := "%" + kw + "%" + db = db.Where("p.pickup_no LIKE ? OR l.listing_no LIKE ? OR a.title LIKE ?", like, like, like) + } + return r.paginatePickups(db, query.Page, query.PageSize) +} + +func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query SellerPickupQuery) (*PaginatedResult, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + db := r.baseQuery(ctx).Where("p.owner_id = ?", ownerID) + if status := strings.TrimSpace(query.Status); status != "" { + db = db.Where("p.status = ?", status) + } + return r.paginatePickups(db, query.Page, query.PageSize) +} + +func (r *Repository) paginatePickups(db *gorm.DB, page, pageSize int) (*PaginatedResult, error) { + page, pageSize = normalizePaging(page, pageSize) + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, err + } + var rows []pickupRow + if err := db.Order("p.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil { + return nil, err + } + items := make([]PickupDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil +} + +// ListAvailableListings 可提号的 listing:已发布、已审核、未在交易中。 +func (r *Repository) ListAvailableListings(ctx context.Context, keyword string, page, pageSize int) (*AvailableListingsResult, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + db := r.db.WithContext(ctx).Table("rental_listings AS l"). + Select("l.id, l.listing_no, l.account_id, a.title AS account_title, a.server_region, a.login_platform, l.owner_id, owner.phone AS owner_phone"). + Joins("JOIN game_accounts AS a ON a.id = l.account_id"). + Joins("JOIN users AS owner ON owner.id = l.owner_id"). + Where("l.status = ? AND l.review_status = ? AND l.in_transaction = ?", "published", "approved", false) + if kw := strings.TrimSpace(keyword); kw != "" { + like := "%" + kw + "%" + db = db.Where("l.listing_no LIKE ? OR a.title LIKE ?", like, like) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, err + } + page, pageSize = normalizePaging(page, pageSize) + var rows []AvailableListingDTO + if err := db.Order("l.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil { + return nil, err + } + return &AvailableListingsResult{Items: rows, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (r *Repository) baseQuery(ctx context.Context) *gorm.DB { + return r.db.WithContext(ctx).Table("admin_pickups AS p"). + Select("p.*, l.listing_no, a.title AS account_title, a.server_region, a.login_platform, owner.phone AS owner_phone"). + Joins("JOIN rental_listings AS l ON l.id = p.listing_id"). + Joins("JOIN game_accounts AS a ON a.id = p.account_id"). + Joins("JOIN users AS owner ON owner.id = p.owner_id") +} + +type pickupRow struct { + model.AdminPickup + ListingNo string + AccountTitle string + ServerRegion string + LoginPlatform string + OwnerPhone string +} + +func (row pickupRow) toDTO() PickupDTO { + return PickupDTO{ + ID: row.ID, + PickupNo: row.PickupNo, + ListingID: row.ListingID, + ListingNo: row.ListingNo, + AccountID: row.AccountID, + AccountTitle: row.AccountTitle, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + OwnerID: row.OwnerID, + OwnerPhone: row.OwnerPhone, + AdminID: row.AdminID, + Platform: row.Platform, + SettleAmountCent: row.SettleAmountCent, + Status: row.Status, + Remark: row.Remark, + CompleteRemark: row.CompleteRemark, + CreatedAt: row.CreatedAt, + CompletedAt: row.CompletedAt, + CancelledAt: row.CancelledAt, + } +} + +func normalizePaging(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + +// newPickupNo 生成提号编号:PK + YYYYMMDDHHMMSS + 3位随机。 +func newPickupNo() (string, error) { + now := timeutil.ShanghaiNow() + timeStr := now.Format("20060102150405") + buf := make([]byte, 2) + if _, err := crand.Read(buf); err != nil { + return "", err + } + randomNum := (int(buf[0])<<8 | int(buf[1])) % 1000 + return "PK" + timeStr + strconv.Itoa(1000+randomNum)[1:], nil +} diff --git a/backend/internal/modules/pickup/service.go b/backend/internal/modules/pickup/service.go new file mode 100644 index 0000000..205d35e --- /dev/null +++ b/backend/internal/modules/pickup/service.go @@ -0,0 +1,73 @@ +package pickup + +import ( + "context" + + "hfb_sys/backend/internal/auditlog" +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Create(ctx context.Context, req CreateRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if req.ListingID == 0 { + return nil, ErrListingUnavailable + } + return s.repo.Create(ctx, req, adminID, meta) +} + +func (s *Service) Complete(ctx context.Context, pickupID uint64, req CompleteRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if pickupID == 0 || req.SettleAmountCent <= 0 { + return nil, ErrInvalidAmount + } + return s.repo.Complete(ctx, pickupID, req, adminID, meta) +} + +func (s *Service) Cancel(ctx context.Context, pickupID uint64, reason string, adminID uint64, meta auditlog.Meta) error { + if s.repo == nil { + return ErrDependencyUnavailable + } + if pickupID == 0 || reason == "" { + return ErrPickupNotPickingUp + } + return s.repo.Cancel(ctx, pickupID, reason, adminID, meta) +} + +func (s *Service) FindByID(ctx context.Context, id uint64) (*PickupDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.FindByID(ctx, id) +} + +func (s *Service) ListAdmin(ctx context.Context, query AdminPickupQuery) (*PaginatedResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListAdmin(ctx, query) +} + +func (s *Service) ListForSeller(ctx context.Context, ownerID uint64, query SellerPickupQuery) (*PaginatedResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListForSeller(ctx, ownerID, query) +} + +func (s *Service) ListAvailableListings(ctx context.Context, keyword string, page, pageSize int) (*AvailableListingsResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + return s.repo.ListAvailableListings(ctx, keyword, page, pageSize) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index ed426b6..b10334d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -29,6 +29,7 @@ import ( "hfb_sys/backend/internal/modules/payment" "hfb_sys/backend/internal/modules/paymentaccount" "hfb_sys/backend/internal/modules/paymentconfig" + "hfb_sys/backend/internal/modules/pickup" "hfb_sys/backend/internal/modules/realname" "hfb_sys/backend/internal/modules/supportgroup" "hfb_sys/backend/internal/modules/systemconfig" @@ -209,6 +210,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } walletService := wallet.NewService(walletRepo) walletHandler := wallet.NewHandler(walletService) + var pickupRepo *pickup.Repository + if deps.DB != nil { + pickupRepo = pickup.NewRepository(deps.DB) + } + pickupService := pickup.NewService(pickupRepo) + pickupHandler := pickup.NewHandler(pickupService) var paymentAccountRepo *paymentaccount.Repository if deps.DB != nil { paymentAccountRepo = paymentaccount.NewRepository(deps.DB, fieldEncryptor) @@ -401,6 +408,12 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { sellerRoutes.GET("/listings/:id", listingHandler.FindMine) } + // 卖家提号记录:仅 requireAuth,不强制 requireRealname,避免撤销实名后看不到自己被提号的记录 + sellerPickupRoutes := api.Group("/seller/pickups", requireAuth) + { + sellerPickupRoutes.GET("", pickupHandler.ListForSeller) + } + orderRoutes := api.Group("/orders", requireAuth) { orderRoutes.POST("", requireRealname, orderHandler.Create) @@ -531,6 +544,15 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/orders/:id/refund/approve", requirePerm("order:close"), orderHandler.AdminApproveRefund) adminRoutes.POST("/orders/:id/refund/reject", requirePerm("order:close"), orderHandler.AdminRejectRefund) adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund) + + // 管理员线下提号(独立于正常订单流程) + adminRoutes.POST("/pickups", requirePerm("order:pickup"), pickupHandler.Create) + adminRoutes.GET("/pickups", requirePerm("order:pickup"), pickupHandler.List) + adminRoutes.GET("/pickups/available-listings", requirePerm("order:pickup"), pickupHandler.AvailableListings) + adminRoutes.GET("/pickups/:id", requirePerm("order:pickup"), pickupHandler.Detail) + adminRoutes.POST("/pickups/:id/complete", requirePerm("order:pickup"), pickupHandler.Complete) + adminRoutes.POST("/pickups/:id/cancel", requirePerm("order:pickup"), pickupHandler.Cancel) + adminRoutes.GET("/listings", requirePerm("listing:view"), listingHandler.ListAdmin) adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview) adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin) diff --git a/backend/migrations/000021_admin_pickups.sql b/backend/migrations/000021_admin_pickups.sql new file mode 100644 index 0000000..29b166d --- /dev/null +++ b/backend/migrations/000021_admin_pickups.sql @@ -0,0 +1,32 @@ +-- +goose Up + +CREATE TABLE admin_pickups ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + pickup_no VARCHAR(64) NOT NULL COMMENT '提号编号 PK+时间+随机', + listing_id BIGINT UNSIGNED NOT NULL COMMENT '关联的发布项', + account_id BIGINT UNSIGNED NOT NULL COMMENT '关联的游戏账号', + owner_id BIGINT UNSIGNED NOT NULL COMMENT '卖家(号主)', + admin_id BIGINT UNSIGNED NOT NULL COMMENT '操作管理员', + platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT '线下交易渠道(微信/QQ/闲鱼等)', + settle_amount_cent BIGINT NOT NULL DEFAULT 0 COMMENT '完成时结算金额(分)', + status VARCHAR(20) NOT NULL DEFAULT 'picking_up' COMMENT '状态: picking_up提号中/completed已完成/cancelled已取消', + remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '创建备注', + complete_remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '完成备注', + created_at DATETIME NOT NULL, + completed_at DATETIME NULL, + cancelled_at DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_pickup_no (pickup_no), + KEY idx_listing (listing_id), + KEY idx_owner (owner_id), + KEY idx_status (status), + KEY idx_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员线下提号记录,独立于 rental_orders'; + +INSERT INTO permissions (code, name, resource, action) +VALUES ('order:pickup', '管理员提号', 'order', 'pickup'); + +-- +goose Down + +DROP TABLE IF EXISTS admin_pickups; +DELETE FROM permissions WHERE code = 'order:pickup'; diff --git a/frontend/src/features/admin/api/adminFinance.ts b/frontend/src/features/admin/api/adminFinance.ts index 1e06590..8330671 100644 --- a/frontend/src/features/admin/api/adminFinance.ts +++ b/frontend/src/features/admin/api/adminFinance.ts @@ -44,9 +44,16 @@ export interface FinanceDailyItem { settled_order_count: number } +export interface FinancePickupSummary { + settled_amount_cent: number + completed_count: number + in_progress_count: number +} + export interface FinanceDashboard { summary: FinanceSummary daily_items: FinanceDailyItem[] + pickup_summary: FinancePickupSummary generated_at: string } @@ -109,6 +116,11 @@ export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) { return { ...data.data, daily_items: Array.isArray(data.data?.daily_items) ? data.data.daily_items : [], + pickup_summary: data.data?.pickup_summary ?? { + settled_amount_cent: 0, + completed_count: 0, + in_progress_count: 0, + }, } } diff --git a/frontend/src/features/admin/api/adminPickup.ts b/frontend/src/features/admin/api/adminPickup.ts new file mode 100644 index 0000000..9ef6091 --- /dev/null +++ b/frontend/src/features/admin/api/adminPickup.ts @@ -0,0 +1,106 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse, PaginatedResult } from '@/shared/types/types' + +export interface AdminPickup { + id: number + pickup_no: string + listing_id: number + listing_no: string + account_id: number + account_title: string + server_region: string + login_platform: string + owner_id: number + owner_phone: string + admin_id: number + platform: string + settle_amount_cent: number + status: string + remark: string + complete_remark: string + created_at: string + completed_at?: string + cancelled_at?: string +} + +export interface AvailableListing { + id: number + listing_no: string + account_id: number + account_title: string + server_region: string + login_platform: string + owner_id: number + owner_phone: string +} + +export interface AdminPickupCreateRequest { + listing_id: number + platform?: string + remark?: string +} + +export interface AdminPickupCompleteRequest { + settle_amount_cent: number + complete_remark?: string +} + +export interface AdminPickupListQuery { + page?: number + page_size?: number + keyword?: string + status?: string +} + +function cleanParams(query: object) { + return Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== '' && value !== undefined) + ) +} + +export async function fetchAdminPickups(query: AdminPickupListQuery = {}) { + const { data } = await apiClient.get>>( + '/admin/pickups', + { params: cleanParams(query) } + ) + const result = data.data + return { + items: Array.isArray(result?.items) ? result.items : [], + total: Number(result?.total ?? 0), + page: Number(result?.page ?? query.page ?? 1), + page_size: Number(result?.page_size ?? query.page_size ?? 20), + } +} + +export async function fetchAvailableListings(keyword: string, page = 1, page_size = 20) { + const { data } = await apiClient.get>>( + '/admin/pickups/available-listings', + { params: cleanParams({ keyword, page, page_size }) } + ) + const result = data.data + return { + items: Array.isArray(result?.items) ? result.items : [], + total: Number(result?.total ?? 0), + } +} + +export async function createAdminPickup(req: AdminPickupCreateRequest) { + const { data } = await apiClient.post>('/admin/pickups', req) + return data.data +} + +export async function completeAdminPickup(id: number, req: AdminPickupCompleteRequest) { + const { data } = await apiClient.post>( + `/admin/pickups/${id}/complete`, + req + ) + return data.data +} + +export async function cancelAdminPickup(id: number, reason: string) { + const { data } = await apiClient.post>( + `/admin/pickups/${id}/cancel`, + { reason } + ) + return data.data +} diff --git a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue index 53be3e4..ef71160 100644 --- a/frontend/src/features/admin/views/AdminFinanceDashboardView.vue +++ b/frontend/src/features/admin/views/AdminFinanceDashboardView.vue @@ -139,6 +139,19 @@ function rowDiffClass(row: FinanceDailyItem) { +
+
+ 线下提号结算 + {{ moneyCent(dashboard.pickup_summary.settled_amount_cent) }} + {{ dashboard.pickup_summary.completed_count }} 笔已完成 +
+
+ 提号中 + {{ dashboard.pickup_summary.in_progress_count }} + 进行中笔数 +
+
+ @@ -193,6 +206,14 @@ function rowDiffClass(row: FinanceDailyItem) { font-weight: 700; } +.pickup-grid { + margin-top: 12px; +} + +.pickup-card { + border-left: 3px solid #6366f1; +} + .generated-at { margin: 12px 0 0; color: #64748b; diff --git a/frontend/src/features/admin/views/AdminPickupView.vue b/frontend/src/features/admin/views/AdminPickupView.vue new file mode 100644 index 0000000..01c06d9 --- /dev/null +++ b/frontend/src/features/admin/views/AdminPickupView.vue @@ -0,0 +1,358 @@ + + + + + diff --git a/frontend/src/features/seller/api/sellerPickup.ts b/frontend/src/features/seller/api/sellerPickup.ts new file mode 100644 index 0000000..47fa7e5 --- /dev/null +++ b/frontend/src/features/seller/api/sellerPickup.ts @@ -0,0 +1,32 @@ +import { apiClient } from '@/shared/api/client' +import type { ApiResponse, PaginatedResult } from '@/shared/types/types' +import type { AdminPickup } from '@/features/admin/api/adminPickup' + +export interface SellerPickupListQuery { + page?: number + page_size?: number + status?: string +} + +function cleanParams(query: object) { + return Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== '' && value !== undefined) + ) +} + +// 复用 AdminPickup 类型:卖家视角字段集合相同(admin_id 也会返回,前端不展示即可) +export type SellerPickup = AdminPickup + +export async function fetchSellerPickups(query: SellerPickupListQuery = {}) { + const { data } = await apiClient.get>>( + '/seller/pickups', + { params: cleanParams(query) } + ) + const result = data.data + return { + items: Array.isArray(result?.items) ? result.items : [], + total: Number(result?.total ?? 0), + page: Number(result?.page ?? query.page ?? 1), + page_size: Number(result?.page_size ?? query.page_size ?? 20), + } +} diff --git a/frontend/src/features/seller/views/SellerPickupsView.vue b/frontend/src/features/seller/views/SellerPickupsView.vue new file mode 100644 index 0000000..a36a4b5 --- /dev/null +++ b/frontend/src/features/seller/views/SellerPickupsView.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/frontend/src/features/wallet/views/WalletView.vue b/frontend/src/features/wallet/views/WalletView.vue index f1ddddd..d2d63e1 100644 --- a/frontend/src/features/wallet/views/WalletView.vue +++ b/frontend/src/features/wallet/views/WalletView.vue @@ -112,6 +112,7 @@ function walletBizTypeLabel(type: string) { checkout_refund: '结账退款', channel_deposit_refund: '押金退还', withdraw_apply: '申请提现', + admin_pickup_settle: '线下提号结算', } return map[type] || type || '-' } diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue index 6665cd7..80b411c 100644 --- a/frontend/src/layouts/AdminLayout.vue +++ b/frontend/src/layouts/AdminLayout.vue @@ -19,6 +19,7 @@ import { Service, Shop, Setting, + Switch, SwitchButton, Tickets, Tools, @@ -89,6 +90,12 @@ const allNavGroups: NavGroup[] = [ icon: Money, permission: 'order:close', }, + { + label: '线下提号', + to: adminPath('pickup'), + icon: Switch, + permission: 'order:pickup', + }, { label: '商品管理', to: adminPath('listings'), icon: Shop, permission: 'listing:view' }, { label: '商品审核', diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index c648bee..7d076a3 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -16,6 +16,7 @@ import { Service, Shop, Star, + Switch, SwitchButton, Tickets, UserFilled, @@ -48,6 +49,7 @@ const buyerServiceItems = [ const sellerServiceItems = [ { label: '发布商品', icon: CirclePlus, to: '/seller/listings/create' }, { label: '我的商品', icon: Shop, to: '/seller/listings' }, + { label: '提号记录', icon: Switch, to: '/seller/pickups' }, { label: '提现/账单', icon: Wallet, to: '/wallet' }, ] @@ -886,27 +888,27 @@ async function handleSupportClick() { .dropdown-seller-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } .dropdown-seller-card { display: grid; + min-width: 0; + min-height: 74px; place-items: center; - align-items: center; - gap: 8px; - min-height: 72px; - padding: 10px 6px; + gap: 6px; + padding: 8px 4px; border-radius: 10px; background: #f8fafc; color: #475569; - font-size: 13px; + font-size: 12px; font-weight: 800; text-align: center; } .dropdown-seller-card .el-icon { - color: #94a3b8; + color: #ff6a00; font-size: 18px; } diff --git a/frontend/src/router/adminRoutes.ts b/frontend/src/router/adminRoutes.ts index e401d66..fe86874 100644 --- a/frontend/src/router/adminRoutes.ts +++ b/frontend/src/router/adminRoutes.ts @@ -36,6 +36,12 @@ export const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/features/admin/views/AdminRefundReviewView.vue'), meta: adminMeta, }, + { + path: adminPath('pickup'), + name: 'admin-pickup', + component: () => import('@/features/admin/views/AdminPickupView.vue'), + meta: adminMeta, + }, { path: adminPath('orders/:id'), name: 'admin-order-detail', diff --git a/frontend/src/router/sellerRoutes.ts b/frontend/src/router/sellerRoutes.ts index f0c3907..0cfe80a 100644 --- a/frontend/src/router/sellerRoutes.ts +++ b/frontend/src/router/sellerRoutes.ts @@ -31,4 +31,10 @@ export const sellerRoutes: RouteRecordRaw[] = [ component: () => import('@/features/seller/views/SellerEarningsView.vue'), meta: { requiresAuth: true }, }, + { + path: '/seller/pickups', + name: 'seller-pickups', + component: () => import('@/features/seller/views/SellerPickupsView.vue'), + meta: { requiresAuth: true }, + }, ] diff --git a/frontend/src/shared/utils/statusLabels.ts b/frontend/src/shared/utils/statusLabels.ts index 03c0bcb..a1d6e4b 100644 --- a/frontend/src/shared/utils/statusLabels.ts +++ b/frontend/src/shared/utils/statusLabels.ts @@ -134,6 +134,12 @@ const balanceTypeMap: Record = { frozen: '冻结余额', } +const pickupStatusMap: Record = { + picking_up: '提号中', + completed: '已完成', + cancelled: '已取消', +} + function readLabel(map: Record, value: string) { return map[value] || value || '-' } @@ -189,3 +195,7 @@ export function ledgerDirectionLabel(direction: string) { export function balanceTypeLabel(type: string) { return readLabel(balanceTypeMap, type) } + +export function pickupStatusLabel(status: string) { + return readLabel(pickupStatusMap, status) +}