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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/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)
|
||||
|
||||
Reference in New Issue
Block a user