feat(pickup): 新增管理员线下提号功能
- 新建 admin_pickups 表独立于 rental_orders,不污染订单状态机与财务统计口径 - 提号中/已完成/已取消三态:创建锁定账号,完成时给卖家钱包加可用余额并下架 listing,取消解锁账号 - 完成时 listing 置 completed,复用现有 listingLockedForOwnerMutation 拦截再上架 - 管理端提号管理页(列表/创建/完成/取消/可提号账号搜索)+ 卖家端提号记录页 - 财务仪表盘新增线下提号独立统计块,与正常订单口径分离 - 钱包流水 label、状态标签、菜单入口同步补齐 - 优化卖家服务悬浮菜单为一行四个,尺寸与配色对齐买家服务
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
@@ -15,10 +15,40 @@ func (r *Repository) Dashboard(ctx context.Context, query DashboardQuery) (*Dash
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
pickup, err := r.pickupSummary(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &DashboardDTO{
|
return &DashboardDTO{
|
||||||
Summary: *summary,
|
Summary: *summary,
|
||||||
DailyItems: dailyItems,
|
DailyItems: dailyItems,
|
||||||
GeneratedAt: timeutil.ShanghaiNow(),
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,17 @@ type DetailQuery struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DashboardDTO struct {
|
type DashboardDTO struct {
|
||||||
Summary FinanceSummaryDTO `json:"summary"`
|
Summary FinanceSummaryDTO `json:"summary"`
|
||||||
DailyItems []FinanceDailyDTO `json:"daily_items"`
|
DailyItems []FinanceDailyDTO `json:"daily_items"`
|
||||||
GeneratedAt time.Time `json:"generated_at"`
|
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 {
|
type FinanceSummaryDTO struct {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/modules/payment"
|
"hfb_sys/backend/internal/modules/payment"
|
||||||
"hfb_sys/backend/internal/modules/paymentaccount"
|
"hfb_sys/backend/internal/modules/paymentaccount"
|
||||||
"hfb_sys/backend/internal/modules/paymentconfig"
|
"hfb_sys/backend/internal/modules/paymentconfig"
|
||||||
|
"hfb_sys/backend/internal/modules/pickup"
|
||||||
"hfb_sys/backend/internal/modules/realname"
|
"hfb_sys/backend/internal/modules/realname"
|
||||||
"hfb_sys/backend/internal/modules/supportgroup"
|
"hfb_sys/backend/internal/modules/supportgroup"
|
||||||
"hfb_sys/backend/internal/modules/systemconfig"
|
"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)
|
walletService := wallet.NewService(walletRepo)
|
||||||
walletHandler := wallet.NewHandler(walletService)
|
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
|
var paymentAccountRepo *paymentaccount.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
paymentAccountRepo = paymentaccount.NewRepository(deps.DB, fieldEncryptor)
|
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)
|
sellerRoutes.GET("/listings/:id", listingHandler.FindMine)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 卖家提号记录:仅 requireAuth,不强制 requireRealname,避免撤销实名后看不到自己被提号的记录
|
||||||
|
sellerPickupRoutes := api.Group("/seller/pickups", requireAuth)
|
||||||
|
{
|
||||||
|
sellerPickupRoutes.GET("", pickupHandler.ListForSeller)
|
||||||
|
}
|
||||||
|
|
||||||
orderRoutes := api.Group("/orders", requireAuth)
|
orderRoutes := api.Group("/orders", requireAuth)
|
||||||
{
|
{
|
||||||
orderRoutes.POST("", requireRealname, orderHandler.Create)
|
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/approve", requirePerm("order:close"), orderHandler.AdminApproveRefund)
|
||||||
adminRoutes.POST("/orders/:id/refund/reject", requirePerm("order:close"), orderHandler.AdminRejectRefund)
|
adminRoutes.POST("/orders/:id/refund/reject", requirePerm("order:close"), orderHandler.AdminRejectRefund)
|
||||||
adminRoutes.GET("/orders/refund-pending", requirePerm("order:close"), orderHandler.ListPendingRefund)
|
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", requirePerm("listing:view"), listingHandler.ListAdmin)
|
||||||
adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview)
|
adminRoutes.GET("/listings/pending", requirePerm("listing:approve"), listingHandler.ListPendingReview)
|
||||||
adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin)
|
adminRoutes.GET("/listings/:id", requirePerm("listing:view"), listingHandler.FindAdmin)
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -44,9 +44,16 @@ export interface FinanceDailyItem {
|
|||||||
settled_order_count: number
|
settled_order_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FinancePickupSummary {
|
||||||
|
settled_amount_cent: number
|
||||||
|
completed_count: number
|
||||||
|
in_progress_count: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface FinanceDashboard {
|
export interface FinanceDashboard {
|
||||||
summary: FinanceSummary
|
summary: FinanceSummary
|
||||||
daily_items: FinanceDailyItem[]
|
daily_items: FinanceDailyItem[]
|
||||||
|
pickup_summary: FinancePickupSummary
|
||||||
generated_at: string
|
generated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +116,11 @@ export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) {
|
|||||||
return {
|
return {
|
||||||
...data.data,
|
...data.data,
|
||||||
daily_items: Array.isArray(data.data?.daily_items) ? data.data.daily_items : [],
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<ApiResponse<PaginatedResult<AdminPickup>>>(
|
||||||
|
'/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<ApiResponse<PaginatedResult<AvailableListing>>>(
|
||||||
|
'/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<ApiResponse<AdminPickup>>('/admin/pickups', req)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeAdminPickup(id: number, req: AdminPickupCompleteRequest) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<AdminPickup>>(
|
||||||
|
`/admin/pickups/${id}/complete`,
|
||||||
|
req
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelAdminPickup(id: number, reason: string) {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<{ cancelled: boolean }>>(
|
||||||
|
`/admin/pickups/${id}/cancel`,
|
||||||
|
{ reason }
|
||||||
|
)
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
@@ -139,6 +139,19 @@ function rowDiffClass(row: FinanceDailyItem) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dashboard?.pickup_summary" class="metric-grid pickup-grid">
|
||||||
|
<div class="metric-card pickup-card">
|
||||||
|
<span>线下提号结算</span>
|
||||||
|
<strong>{{ moneyCent(dashboard.pickup_summary.settled_amount_cent) }}</strong>
|
||||||
|
<small>{{ dashboard.pickup_summary.completed_count }} 笔已完成</small>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card pickup-card">
|
||||||
|
<span>提号中</span>
|
||||||
|
<strong>{{ dashboard.pickup_summary.in_progress_count }}</strong>
|
||||||
|
<small>进行中笔数</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="dailyItems">
|
<el-table v-loading="loading" class="table-panel" :data="dailyItems">
|
||||||
<el-table-column prop="date" label="日期" width="130" />
|
<el-table-column prop="date" label="日期" width="130" />
|
||||||
<el-table-column label="总流水" width="130">
|
<el-table-column label="总流水" width="130">
|
||||||
@@ -193,6 +206,14 @@ function rowDiffClass(row: FinanceDailyItem) {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pickup-grid {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickup-card {
|
||||||
|
border-left: 3px solid #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
.generated-at {
|
.generated-at {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
cancelAdminPickup,
|
||||||
|
completeAdminPickup,
|
||||||
|
createAdminPickup,
|
||||||
|
fetchAdminPickups,
|
||||||
|
fetchAvailableListings,
|
||||||
|
type AdminPickup,
|
||||||
|
type AvailableListing,
|
||||||
|
} from '@/features/admin/api/adminPickup'
|
||||||
|
import { formatCentWithSymbol } from '@/shared/utils/money'
|
||||||
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
import { pickupStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const items = ref<AdminPickup[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const filters = reactive({ page: 1, page_size: 20, keyword: '', status: '' })
|
||||||
|
|
||||||
|
const createDialogVisible = ref(false)
|
||||||
|
const completeDialogVisible = ref(false)
|
||||||
|
const activePickupId = ref(0)
|
||||||
|
|
||||||
|
const createForm = reactive({
|
||||||
|
listing_id: null as number | null,
|
||||||
|
platform: '',
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
const completeForm = reactive({ settle_amount: 0, complete_remark: '' })
|
||||||
|
|
||||||
|
const listingOptions = ref<AvailableListing[]>([])
|
||||||
|
const listingLoading = ref(false)
|
||||||
|
|
||||||
|
onMounted(loadList)
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchAdminPickups(filters)
|
||||||
|
items.value = result.items
|
||||||
|
total.value = result.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
filters.page = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
function handlePageChange(p: number) {
|
||||||
|
filters.page = p
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
function handleSizeChange(s: number) {
|
||||||
|
filters.page_size = s
|
||||||
|
filters.page = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
createForm.listing_id = null
|
||||||
|
createForm.platform = ''
|
||||||
|
createForm.remark = ''
|
||||||
|
listingOptions.value = []
|
||||||
|
createDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchListings(keyword: string) {
|
||||||
|
if (!keyword) {
|
||||||
|
listingOptions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listingLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchAvailableListings(keyword)
|
||||||
|
listingOptions.value = result.items
|
||||||
|
} finally {
|
||||||
|
listingLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!createForm.listing_id) {
|
||||||
|
ElMessage.warning('请选择要提号的账号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await createAdminPickup({
|
||||||
|
listing_id: createForm.listing_id,
|
||||||
|
platform: createForm.platform,
|
||||||
|
remark: createForm.remark,
|
||||||
|
})
|
||||||
|
ElMessage.success('提号订单已创建')
|
||||||
|
createDialogVisible.value = false
|
||||||
|
loadList()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
ElMessage.error(errorMessage(e) || '创建失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCompleteDialog(id: number) {
|
||||||
|
activePickupId.value = id
|
||||||
|
completeForm.settle_amount = 0
|
||||||
|
completeForm.complete_remark = ''
|
||||||
|
completeDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComplete() {
|
||||||
|
if (completeForm.settle_amount <= 0) {
|
||||||
|
ElMessage.warning('请输入结算金额')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await completeAdminPickup(activePickupId.value, {
|
||||||
|
settle_amount_cent: Math.round(completeForm.settle_amount * 100),
|
||||||
|
complete_remark: completeForm.complete_remark,
|
||||||
|
})
|
||||||
|
ElMessage.success('提号已完成,已给卖家结算')
|
||||||
|
completeDialogVisible.value = false
|
||||||
|
loadList()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
ElMessage.error(errorMessage(e) || '完成失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel(id: number) {
|
||||||
|
try {
|
||||||
|
const { value } = await ElMessageBox.prompt('请输入取消原因', '取消提号', {
|
||||||
|
confirmButtonText: '确认取消',
|
||||||
|
cancelButtonText: '返回',
|
||||||
|
inputPattern: /.+/,
|
||||||
|
inputErrorMessage: '取消原因不能为空',
|
||||||
|
})
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await cancelAdminPickup(id, value)
|
||||||
|
ElMessage.success('提号已取消')
|
||||||
|
loadList()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
ElMessage.error(errorMessage(e) || '取消失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 用户放弃 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusType(status: string) {
|
||||||
|
if (status === 'picking_up') return 'warning'
|
||||||
|
if (status === 'completed') return 'success'
|
||||||
|
if (status === 'cancelled') return 'info'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(e: unknown) {
|
||||||
|
if (typeof e === 'object' && e !== null && 'message' in e) {
|
||||||
|
return String((e as { message?: unknown }).message)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<div class="page-header-row">
|
||||||
|
<div class="page-header">
|
||||||
|
<p class="eyebrow">Admin Pickup</p>
|
||||||
|
<h1>线下提号</h1>
|
||||||
|
<p>管理员线下提号,跳过支付与交接流程,完成后给卖家结算余额。</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openCreateDialog">创建提号</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.keyword"
|
||||||
|
placeholder="提号编号 / 上架编号 / 账号标题"
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
/>
|
||||||
|
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 130px">
|
||||||
|
<el-option label="提号中" value="picking_up" />
|
||||||
|
<el-option label="已完成" value="completed" />
|
||||||
|
<el-option label="已取消" value="cancelled" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="items" class="table-panel">
|
||||||
|
<el-table-column prop="pickup_no" label="提号编号" width="190" />
|
||||||
|
<el-table-column prop="account_title" label="账号" min-width="180" />
|
||||||
|
<el-table-column label="区服/平台" width="150">
|
||||||
|
<template #default="{ row }">{{ row.server_region || '-' }} / {{ row.login_platform || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="上架编号" width="120">
|
||||||
|
<template #default="{ row }">{{ row.listing_no }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="卖家" width="140">
|
||||||
|
<template #default="{ row }">{{ row.owner_phone }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="交易渠道" width="110">
|
||||||
|
<template #default="{ row }">{{ row.platform || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="结算金额" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.status === 'completed'">{{ formatCentWithSymbol(row.settle_amount_cent) }}</span>
|
||||||
|
<span v-else class="text-muted">待结算</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusType(row.status)" effect="light">
|
||||||
|
{{ pickupStatusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="创建时间" width="170">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'picking_up'"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="openCompleteDialog(row.id)"
|
||||||
|
>
|
||||||
|
完成结算
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="row.status === 'picking_up'"
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
plain
|
||||||
|
@click="handleCancel(row.id)"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<span v-if="row.status !== 'picking_up'" class="text-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrapper">
|
||||||
|
<el-pagination
|
||||||
|
:current-page="filters.page"
|
||||||
|
:page-size="filters.page_size"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建提号 -->
|
||||||
|
<el-dialog v-model="createDialogVisible" title="创建线下提号" width="520px">
|
||||||
|
<el-form label-width="110px">
|
||||||
|
<el-form-item label="选择账号" required>
|
||||||
|
<el-select
|
||||||
|
v-model="createForm.listing_id"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
clearable
|
||||||
|
:remote-method="searchListings"
|
||||||
|
:loading="listingLoading"
|
||||||
|
placeholder="输入上架编号或账号标题搜索"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in listingOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.listing_no} · ${item.account_title}`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<div class="form-hint">仅显示已发布、已审核、未在交易中的账号</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="交易渠道">
|
||||||
|
<el-input v-model="createForm.platform" placeholder="如 微信 / QQ / 闲鱼" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input
|
||||||
|
v-model="createForm.remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="线下交易说明(可选)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-alert type="info" :closable="false">
|
||||||
|
创建后账号将被锁定为"提号中",卖家收到通知;不会立即结算,需手动完成结算。
|
||||||
|
</el-alert>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleCreate">创建提号</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 完成结算 -->
|
||||||
|
<el-dialog v-model="completeDialogVisible" title="完成提号结算" width="480px">
|
||||||
|
<el-form label-width="110px">
|
||||||
|
<el-form-item label="结算金额(元)" required>
|
||||||
|
<el-input-number
|
||||||
|
v-model="completeForm.settle_amount"
|
||||||
|
:min="0.01"
|
||||||
|
:step="1"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
<div class="form-hint">给卖家钱包增加的可用余额</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="完成备注">
|
||||||
|
<el-input
|
||||||
|
v-model="completeForm.complete_remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="结算说明(可选)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-alert type="warning" :closable="false">
|
||||||
|
完成后订单置为"已完成",卖家钱包立即入账,账号下架不可再上架。操作不可撤销。
|
||||||
|
</el-alert>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="completeDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleComplete">确认结算</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.text-muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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<ApiResponse<PaginatedResult<SellerPickup>>>(
|
||||||
|
'/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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import { fetchSellerPickups, type SellerPickup } from '@/features/seller/api/sellerPickup'
|
||||||
|
import { formatCentWithSymbol } from '@/shared/utils/money'
|
||||||
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
|
import { pickupStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const items = ref<SellerPickup[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const filters = reactive({ page: 1, page_size: 20, status: '' })
|
||||||
|
|
||||||
|
onMounted(loadList)
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchSellerPickups(filters)
|
||||||
|
items.value = result.items
|
||||||
|
total.value = result.total
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
filters.page = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
function handlePageChange(p: number) {
|
||||||
|
filters.page = p
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
function handleSizeChange(s: number) {
|
||||||
|
filters.page_size = s
|
||||||
|
filters.page = 1
|
||||||
|
loadList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusType(status: string) {
|
||||||
|
if (status === 'picking_up') return 'warning'
|
||||||
|
if (status === 'completed') return 'success'
|
||||||
|
if (status === 'cancelled') return 'info'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page">
|
||||||
|
<div class="page-header-row">
|
||||||
|
<div class="page-header">
|
||||||
|
<p class="eyebrow">Seller Pickup</p>
|
||||||
|
<h1>提号记录</h1>
|
||||||
|
<p>您的账号被管理员线下提号的记录,完成后结算金额会进入钱包余额。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<el-select v-model="filters.status" placeholder="状态" clearable style="width: 130px">
|
||||||
|
<el-option label="提号中" value="picking_up" />
|
||||||
|
<el-option label="已完成" value="completed" />
|
||||||
|
<el-option label="已取消" value="cancelled" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||||
|
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="items" class="table-panel">
|
||||||
|
<el-table-column prop="pickup_no" label="提号编号" width="190" />
|
||||||
|
<el-table-column prop="account_title" label="账号" min-width="180" />
|
||||||
|
<el-table-column label="区服/平台" width="150">
|
||||||
|
<template #default="{ row }">{{ row.server_region || '-' }} / {{ row.login_platform || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="交易渠道" width="110">
|
||||||
|
<template #default="{ row }">{{ row.platform || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="结算金额" width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.status === 'completed'" class="amount-in">
|
||||||
|
{{ formatCentWithSymbol(row.settle_amount_cent) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusType(row.status)" effect="light">
|
||||||
|
{{ pickupStatusLabel(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时间" width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.status === 'completed' && row.completed_at">
|
||||||
|
{{ formatDateTime(row.completed_at) }}
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ formatDateTime(row.created_at) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.status === 'completed' && row.complete_remark">{{ row.complete_remark }}</span>
|
||||||
|
<span v-else-if="row.remark">{{ row.remark }}</span>
|
||||||
|
<span v-else class="text-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination-wrapper">
|
||||||
|
<el-pagination
|
||||||
|
:current-page="filters.page"
|
||||||
|
:page-size="filters.page_size"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.amount-in {
|
||||||
|
color: #16a34a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -112,6 +112,7 @@ function walletBizTypeLabel(type: string) {
|
|||||||
checkout_refund: '结账退款',
|
checkout_refund: '结账退款',
|
||||||
channel_deposit_refund: '押金退还',
|
channel_deposit_refund: '押金退还',
|
||||||
withdraw_apply: '申请提现',
|
withdraw_apply: '申请提现',
|
||||||
|
admin_pickup_settle: '线下提号结算',
|
||||||
}
|
}
|
||||||
return map[type] || type || '-'
|
return map[type] || type || '-'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Service,
|
Service,
|
||||||
Shop,
|
Shop,
|
||||||
Setting,
|
Setting,
|
||||||
|
Switch,
|
||||||
SwitchButton,
|
SwitchButton,
|
||||||
Tickets,
|
Tickets,
|
||||||
Tools,
|
Tools,
|
||||||
@@ -89,6 +90,12 @@ const allNavGroups: NavGroup[] = [
|
|||||||
icon: Money,
|
icon: Money,
|
||||||
permission: 'order:close',
|
permission: 'order:close',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '线下提号',
|
||||||
|
to: adminPath('pickup'),
|
||||||
|
icon: Switch,
|
||||||
|
permission: 'order:pickup',
|
||||||
|
},
|
||||||
{ label: '商品管理', to: adminPath('listings'), icon: Shop, permission: 'listing:view' },
|
{ label: '商品管理', to: adminPath('listings'), icon: Shop, permission: 'listing:view' },
|
||||||
{
|
{
|
||||||
label: '商品审核',
|
label: '商品审核',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Service,
|
Service,
|
||||||
Shop,
|
Shop,
|
||||||
Star,
|
Star,
|
||||||
|
Switch,
|
||||||
SwitchButton,
|
SwitchButton,
|
||||||
Tickets,
|
Tickets,
|
||||||
UserFilled,
|
UserFilled,
|
||||||
@@ -48,6 +49,7 @@ const buyerServiceItems = [
|
|||||||
const sellerServiceItems = [
|
const sellerServiceItems = [
|
||||||
{ label: '发布商品', icon: CirclePlus, to: '/seller/listings/create' },
|
{ label: '发布商品', icon: CirclePlus, to: '/seller/listings/create' },
|
||||||
{ label: '我的商品', icon: Shop, to: '/seller/listings' },
|
{ label: '我的商品', icon: Shop, to: '/seller/listings' },
|
||||||
|
{ label: '提号记录', icon: Switch, to: '/seller/pickups' },
|
||||||
{ label: '提现/账单', icon: Wallet, to: '/wallet' },
|
{ label: '提现/账单', icon: Wallet, to: '/wallet' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -886,27 +888,27 @@ async function handleSupportClick() {
|
|||||||
|
|
||||||
.dropdown-seller-grid {
|
.dropdown-seller-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-seller-card {
|
.dropdown-seller-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 74px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
align-items: center;
|
gap: 6px;
|
||||||
gap: 8px;
|
padding: 8px 4px;
|
||||||
min-height: 72px;
|
|
||||||
padding: 10px 6px;
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-seller-card .el-icon {
|
.dropdown-seller-card .el-icon {
|
||||||
color: #94a3b8;
|
color: #ff6a00;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export const adminRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/features/admin/views/AdminRefundReviewView.vue'),
|
component: () => import('@/features/admin/views/AdminRefundReviewView.vue'),
|
||||||
meta: adminMeta,
|
meta: adminMeta,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: adminPath('pickup'),
|
||||||
|
name: 'admin-pickup',
|
||||||
|
component: () => import('@/features/admin/views/AdminPickupView.vue'),
|
||||||
|
meta: adminMeta,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: adminPath('orders/:id'),
|
path: adminPath('orders/:id'),
|
||||||
name: 'admin-order-detail',
|
name: 'admin-order-detail',
|
||||||
|
|||||||
@@ -31,4 +31,10 @@ export const sellerRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/features/seller/views/SellerEarningsView.vue'),
|
component: () => import('@/features/seller/views/SellerEarningsView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/seller/pickups',
|
||||||
|
name: 'seller-pickups',
|
||||||
|
component: () => import('@/features/seller/views/SellerPickupsView.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -134,6 +134,12 @@ const balanceTypeMap: Record<BalanceType, string> = {
|
|||||||
frozen: '冻结余额',
|
frozen: '冻结余额',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pickupStatusMap: Record<string, string> = {
|
||||||
|
picking_up: '提号中',
|
||||||
|
completed: '已完成',
|
||||||
|
cancelled: '已取消',
|
||||||
|
}
|
||||||
|
|
||||||
function readLabel(map: Record<string, string>, value: string) {
|
function readLabel(map: Record<string, string>, value: string) {
|
||||||
return map[value] || value || '-'
|
return map[value] || value || '-'
|
||||||
}
|
}
|
||||||
@@ -189,3 +195,7 @@ export function ledgerDirectionLabel(direction: string) {
|
|||||||
export function balanceTypeLabel(type: string) {
|
export function balanceTypeLabel(type: string) {
|
||||||
return readLabel(balanceTypeMap, type)
|
return readLabel(balanceTypeMap, type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pickupStatusLabel(status: string) {
|
||||||
|
return readLabel(pickupStatusMap, status)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user