chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
@@ -0,0 +1,202 @@
package pickup
import (
"context"
"testing"
"time"
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestClonedProdDataOperations(t *testing.T) {
dsn := "hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Skipf("无法连接本地 hfb_sys (可能在CI环境或容器未开启): %v", err)
}
var pickupCount int64
if err := db.Model(&model.AdminPickup{}).Count(&pickupCount).Error; err != nil || pickupCount < 1000 {
t.Skipf("本地 hfb_sys 未包含生产克隆数据 (当前 count=%d, err=%v),跳过克隆数据回归测试", pickupCount, err)
}
repo := NewRepository(db)
ctx := context.Background()
t.Logf("=== 1. 验证克隆生产环境数据基础特征 (单量: %d) ===", pickupCount)
if pickupCount != 4498 {
t.Logf("注意: 当前提号记录数为 %d (线上基准 4498)", pickupCount)
}
// 为保证测试幂等性,先将测试用的几个变体恢复至原始未映射状态
db.Exec("DELETE FROM pickup_shop_mappings WHERE source_name IN ('王大锤游戏专营', '淘宝王大锤商行', '王大锤商行')")
db.Exec("UPDATE admin_pickups SET shop_name = raw_shop_name WHERE raw_shop_name IN ('王大锤游戏专营', '淘宝王大锤商行', '王大锤商行')")
// 1. 获取初始历史聚合统计
stats, err := repo.GetHistoricalShopStats(ctx)
if err != nil {
t.Fatalf("GetHistoricalShopStats() 失败: %v", err)
}
t.Logf("统计概览: 总店铺数=%d, 涉及历史提号总单量=%d, 标准店=%d, 已映射=%d, 待映射=%d",
stats.TotalShops, stats.TotalPickups, stats.StandardCount, stats.MappedCount, stats.UnmappedCount)
if stats.TotalPickups != pickupCount {
t.Fatalf("统计单量与数据库记录数不一致: 统计=%d, 表总数=%d", stats.TotalPickups, pickupCount)
}
if stats.TotalShops < 60 {
t.Fatalf("店铺种类数与线上统计不符: %d (预期 >= 60)", stats.TotalShops)
}
// 打印 Top 5 店铺
for i := 0; i < 5 && i < len(stats.Items); i++ {
t.Logf(" Top %d: 「%s」 单量=%d, 状态=%s", i+1, stats.Items[i].SourceName, stats.Items[i].Count, stats.Items[i].MappingStatus)
}
// 2. 测试一键将「糖浆」设为标准店铺
t.Log("=== 2. 测试一键收录标准店铺 「糖浆」 ===")
// 先检查是否已有
var existingTangjiang model.PickupShop
if err := db.Where("name = ?", "糖浆").First(&existingTangjiang).Error; err == gorm.ErrRecordNotFound {
_, err := repo.CreateShop(ctx, CreatePickupShopRequest{
Name: "糖浆",
Platform: "拼多多",
Status: PickupShopStatusActive,
SortOrder: 100,
Remark: "测试收录",
}, 1, auditlog.Meta{})
if err != nil {
t.Fatalf("收录「糖浆」失败: %v", err)
}
}
// 重新统计,验证「糖浆」状态变为 standard
statsAfterTangjiang, err := repo.GetHistoricalShopStats(ctx)
if err != nil {
t.Fatalf("重新获取统计失败: %v", err)
}
var tangjiangItem *PickupShopHistoricalItemDTO
for _, it := range statsAfterTangjiang.Items {
if it.SourceName == "糖浆" {
tangjiangItem = &it
break
}
}
if tangjiangItem == nil || tangjiangItem.MappingStatus != "standard" {
t.Fatalf("「糖浆」状态未变为 standard: %+v", tangjiangItem)
}
t.Logf("✓ 「糖浆」成功识别为标准店铺 (历史单量: %d)", tangjiangItem.Count)
// 3. 测试批量映射与数据清洗
t.Log("=== 3. 测试批量映射变体至标准店铺 「王大锤游戏」 ===")
// 先确保目标标准店铺「王大锤游戏」存在
var stdDachui model.PickupShop
if err := db.Where("name = ?", "王大锤游戏").First(&stdDachui).Error; err == gorm.ErrRecordNotFound {
createdDachui, err := repo.CreateShop(ctx, CreatePickupShopRequest{
Name: "王大锤游戏",
Platform: "淘宝",
Status: PickupShopStatusActive,
}, 1, auditlog.Meta{})
if err != nil {
t.Fatalf("创建「王大锤游戏」失败: %v", err)
}
stdDachui = model.PickupShop{ID: createdDachui.ID, Name: createdDachui.Name}
}
// 待合并清洗变体:王大锤游戏专营 (36单), 淘宝王大锤商行 (3单), 王大锤商行 (1单)
variants := []string{"王大锤游戏专营", "淘宝王大锤商行", "王大锤商行"}
mapRes, err := repo.MapHistoricalShops(ctx, MapHistoricalShopsRequest{
FromShopNames: variants,
TargetShopName: "王大锤游戏",
Remark: "自动化回归批量合并测试",
}, 1, auditlog.Meta{})
if err != nil {
t.Fatalf("MapHistoricalShops() 失败: %v", err)
}
t.Logf("✓ 批量映射成功: 清洗更新提号单数 = %d, 目标店铺 = %s (ID=%d)",
mapRes.UpdatedPickupsCount, mapRes.TargetShopName, mapRes.TargetShopID)
if mapRes.UpdatedPickupsCount != 40 {
t.Fatalf("预期清洗 40 单 (36+3+1), 实际清洗 = %d", mapRes.UpdatedPickupsCount)
}
// 4. 验证数据库中这 40 单的 shop_name 和 raw_shop_name
t.Log("=== 4. 验证提号单持久化字段状态 ===")
var checkRows []struct {
ID uint64
ShopName string
RawShopName string
}
if err := db.Model(&model.AdminPickup{}).
Where("raw_shop_name IN ?", variants).
Scan(&checkRows).Error; err != nil {
t.Fatalf("查询已清洗记录失败: %v", err)
}
if len(checkRows) != 40 {
t.Fatalf("查询到已清洗记录数 = %d, want 40", len(checkRows))
}
for _, r := range checkRows {
if r.ShopName != "王大锤游戏" {
t.Fatalf("ID=%d shop_name 未更新为目标: %q", r.ID, r.ShopName)
}
if r.RawShopName == "" || r.RawShopName == "王大锤游戏" {
t.Fatalf("ID=%d raw_shop_name 丢失了原始字样: %q", r.ID, r.RawShopName)
}
}
t.Log("✓ 全部 40 单提号 shop_name 已清洗为「王大锤游戏」,且 raw_shop_name 完美保留原始变体字样!")
// 5. 验证后台列表查询与店铺聚合过滤
t.Log("=== 5. 验证后台提号列表筛选 ===")
listRes, err := repo.ListAdmin(ctx, AdminPickupQuery{
ShopName: "王大锤游戏",
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("按「王大锤游戏」筛选列表失败: %v", err)
}
t.Logf("✓ 按「王大锤游戏」筛选总单量: %d", listRes.Total)
if listRes.Total < 880 {
t.Fatalf("合并后「王大锤游戏」总单量预期 >= 880, 实际: %d", listRes.Total)
}
// 验证时间范围筛选
startTime, _ := time.Parse("2006-01-02", "2026-08-01")
endTime, _ := time.Parse("2006-01-02", "2026-08-31")
timeRes, err := repo.ListAdmin(ctx, AdminPickupQuery{
StartTime: &startTime,
EndTime: &endTime,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("时间范围筛选失败: %v", err)
}
t.Logf("✓ 2026年8月提号单量: %d", timeRes.Total)
if timeRes.Total == 0 {
t.Fatal("2026年8月提号单量不应为 0")
}
// 6. 测试撤销映射并还原数据
t.Log("=== 6. 测试撤销映射规则并还原提号单 ===")
if err := repo.DeleteShopMapping(ctx, "王大锤商行", 1, auditlog.Meta{}); err != nil {
t.Fatalf("撤销「王大锤商行」映射失败: %v", err)
}
var restoredRow model.AdminPickup
if err := db.Where("raw_shop_name = ?", "王大锤商行").First(&restoredRow).Error; err != nil {
t.Fatalf("查询还原记录失败: %v", err)
}
if restoredRow.ShopName != "王大锤商行" {
t.Fatalf("撤销后 shop_name 未还原为原始值: 实际 %q, 期望 %q", restoredRow.ShopName, "王大锤商行")
}
t.Logf("✓ 「王大锤商行」映射已成功撤销,原提号单 shop_name 已安全还原为「王大锤商行」!")
t.Log("=== 全部克隆数据回归测试用例验证通过! ===")
}
+287
View File
@@ -0,0 +1,287 @@
package pickup
import (
"errors"
"time"
"gorm.io/datatypes"
)
// 提号状态
const (
StatusPickingUp = "picking_up"
StatusCompleted = "completed"
StatusCancelled = "cancelled"
AccountSourceInternal = "internal"
AccountSourceExternalPlatformManaged = "external_platform_managed"
ListingSourceExternal = "external"
ListingSourceInternal = "internal"
SettlementModeOwnerWallet = "owner_wallet"
SettlementModePlatformManaged = "platform_managed"
OfflineSettlementStatusNone = "none"
OfflineSettlementStatusPending = "pending"
OfflineSettlementStatusSettled = "settled"
FinancialAdjustmentStatusSettled = "settled"
FinancialAdjustmentStatusPending = "pending"
FinancialAdjustmentStatusRecovery = "recovery_pending"
PickupShopStatusActive = "active"
PickupShopStatusDisabled = "disabled"
)
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")
ErrInvalidProfit = errors.New("invalid profit amount")
ErrDuplicatePickup = errors.New("duplicate pickup in progress")
ErrOfflineSettlementCannotMark = errors.New("pickup offline settlement cannot be marked")
ErrFinancialAdjustmentInvalid = errors.New("financial adjustment invalid")
ErrFinancialAdjustmentNotFound = errors.New("financial adjustment not found")
ErrFinancialAdjustmentCannotSettle = errors.New("financial adjustment cannot be settled")
ErrPickupShopNotFound = errors.New("pickup shop not found")
ErrPickupShopNameExists = errors.New("pickup shop name already exists")
ErrPickupCancelled = errors.New("pickup is cancelled")
ErrInvalidThirdPartyOrderNo = errors.New("invalid third party order no")
)
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"`
Operator string `json:"operator"`
Platform string `json:"platform"`
ThirdPartyOrderNo string `json:"third_party_order_no"`
ShopName string `json:"shop_name"`
RawShopName string `json:"raw_shop_name"`
AccountSource string `json:"account_source"`
SourceChannel string `json:"source_channel"`
SettlementMode string `json:"settlement_mode"`
ListingPriceCent int64 `json:"listing_price_cent"`
OrderAmountCent int64 `json:"order_amount_cent"`
DepositAmountCent int64 `json:"deposit_amount_cent"`
OwnerPriceCent int64 `json:"owner_price_cent"`
WebsiteProfitCent int64 `json:"website_profit_cent"`
ProfitAmountCent int64 `json:"profit_amount_cent"`
ProfitAdjustmentCent int64 `json:"profit_adjustment_cent"`
EffectiveProfitAmountCent int64 `json:"effective_profit_amount_cent"`
SellerRatio float64 `json:"seller_ratio"`
BuyerRatio float64 `json:"buyer_ratio"`
AccountSnapshot datatypes.JSON `json:"account_snapshot,omitempty"`
SettleAmountCent int64 `json:"settle_amount_cent"`
SettleAdjustmentCent int64 `json:"settle_adjustment_cent"`
EffectiveSettleAmountCent int64 `json:"effective_settle_amount_cent"`
Status string `json:"status"`
OfflineSettlementStatus string `json:"offline_settlement_status"`
OfflineSettlementRemark string `json:"offline_settlement_remark"`
OfflineSettledBy *uint64 `json:"offline_settled_by,omitempty"`
OfflineSettledAt *time.Time `json:"offline_settled_at,omitempty"`
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 FinancialAdjustmentDTO struct {
ID uint64 `json:"id"`
PickupID uint64 `json:"pickup_id"`
ProfitDeltaCent int64 `json:"profit_delta_cent"`
SettleDeltaCent int64 `json:"settle_delta_cent"`
SettlementMode string `json:"settlement_mode"`
Status string `json:"status"`
Reason string `json:"reason"`
CreatedBy uint64 `json:"created_by"`
SettledBy *uint64 `json:"settled_by,omitempty"`
SettledAt *time.Time `json:"settled_at,omitempty"`
SettlementRemark string `json:"settlement_remark"`
CreatedAt time.Time `json:"created_at"`
}
type CreateRequest struct {
ListingID uint64 `json:"listing_id" binding:"required"`
Operator string `json:"operator" binding:"max=64"`
Platform string `json:"platform"`
ThirdPartyOrderNo string `json:"third_party_order_no" binding:"max=128"`
ShopName string `json:"shop_name" binding:"max=100"`
OrderAmountCent int64 `json:"order_amount_cent" binding:"min=0"`
DepositAmountCent int64 `json:"deposit_amount_cent" binding:"min=0"`
ProfitAmountCent int64 `json:"profit_amount_cent"`
Remark string `json:"remark"`
}
type CompleteRequest struct {
SettleAmountCent int64 `json:"settle_amount_cent" binding:"required,min=1"`
ProfitAmountCent *int64 `json:"profit_amount_cent"`
CompleteRemark string `json:"complete_remark"`
}
type OfflineSettlementRequest struct {
Remark string `json:"remark" binding:"max=255"`
}
type UpdateProfitRequest struct {
ProfitAmountCent int64 `json:"profit_amount_cent" binding:"min=0"`
Reason string `json:"reason"`
}
type UpdateThirdPartyOrderNoRequest struct {
ThirdPartyOrderNo string `json:"third_party_order_no" binding:"required,max=128"`
}
// FinancialAdjustmentRequest 按目标金额创建完成后的差额流水,至少提供一个金额。
type FinancialAdjustmentRequest struct {
ProfitAmountCent *int64 `json:"profit_amount_cent"`
SettleAmountCent *int64 `json:"settle_amount_cent"`
Reason string `json:"reason" binding:"required,max=255"`
}
type FinancialAdjustmentSettlementRequest struct {
Remark string `json:"remark" binding:"max=255"`
}
type CancelRequest struct {
Reason string `json:"reason" binding:"required"`
}
type AdminPickupQuery struct {
Page int
PageSize int
Keyword string
Status string
ShopName string
AccountSource string
OfflineSettlementStatus string
Operator string
ThirdPartyOrderNo string
StartTime *time.Time
EndTime *time.Time
}
type PickupShopDTO struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreatePickupShopRequest struct {
Name string `json:"name" binding:"required,max=100"`
Platform string `json:"platform" binding:"max=32"`
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
SortOrder int `json:"sort_order"`
Remark string `json:"remark" binding:"max=255"`
}
type UpdatePickupShopRequest struct {
Name string `json:"name" binding:"required,max=100"`
Platform string `json:"platform" binding:"max=32"`
Status string `json:"status" binding:"required,oneof=active disabled"`
SortOrder int `json:"sort_order"`
Remark string `json:"remark" binding:"max=255"`
}
type PickupShopQuery struct {
Keyword string
Platform string
Status string
}
type PickupShopHistoricalItemDTO struct {
SourceName string `json:"source_name"`
Count int64 `json:"count"`
MappingStatus string `json:"mapping_status"` // standard, mapped, unmapped
TargetShopID uint64 `json:"target_shop_id,omitempty"`
TargetShopName string `json:"target_shop_name,omitempty"`
Remark string `json:"remark,omitempty"`
}
type PickupShopHistoricalStatsDTO struct {
Items []PickupShopHistoricalItemDTO `json:"items"`
TotalPickups int64 `json:"total_pickups"`
TotalShops int `json:"total_shops"`
StandardCount int `json:"standard_count"`
MappedCount int `json:"mapped_count"`
UnmappedCount int `json:"unmapped_count"`
}
type MapHistoricalShopsRequest struct {
FromShopNames []string `json:"from_shop_names" binding:"required,min=1"`
TargetShopName string `json:"target_shop_name" binding:"required,max=100"`
CreateTargetMissing bool `json:"create_target_missing"`
TargetPlatform string `json:"target_platform" binding:"max=32"`
Remark string `json:"remark" binding:"max=255"`
}
type MapHistoricalShopsResultDTO struct {
UpdatedPickupsCount int64 `json:"updated_pickups_count"`
TargetShopName string `json:"target_shop_name"`
TargetShopID uint64 `json:"target_shop_id"`
CreatedStandardShop bool `json:"created_standard_shop"`
}
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"`
ListingPriceCent int64 `json:"listing_price_cent"`
OwnerPriceCent int64 `json:"owner_price_cent"`
OwnerPurePriceCent int64 `json:"owner_pure_price_cent"`
OwnerExtraItemPriceCent int64 `json:"owner_extra_item_price_cent"`
OwnerTotalPriceCent int64 `json:"owner_total_price_cent"`
WebsiteProfitCent int64 `json:"website_profit_cent"`
SellerRatio float64 `json:"seller_ratio"`
BuyerRatio float64 `json:"buyer_ratio"`
AccountSource string `json:"account_source"`
IsExternalUpload bool `json:"is_external_upload"`
SourceChannel string `json:"source_channel"`
HandoffMode string `json:"handoff_mode"`
SettlementMode string `json:"settlement_mode"`
}
type AvailableListingQuery struct {
Page int
PageSize int
Keyword string
SourceType string
}
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"`
}
+469
View File
@@ -0,0 +1,469 @@
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)
}
// MarkOfflineSettlement 确认平台代管提号已完成实际线下打款(管理员)。
func (h *Handler) MarkOfflineSettlement(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req OfflineSettlementRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "线下结算备注格式不正确")
return
}
item, err := h.service.MarkOfflineSettlement(c.Request.Context(), id, req, adminID, auditMeta(c))
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, item)
}
// UpdateProfit 修改提号中的线下利润(管理员)
func (h *Handler) UpdateProfit(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req UpdateProfitRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "利润金额不正确")
return
}
item, err := h.service.UpdateProfit(c.Request.Context(), id, req, adminID, auditMeta(c))
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, item)
}
// UpdateThirdPartyOrderNo 补录或修改第三方单号(管理员)
func (h *Handler) UpdateThirdPartyOrderNo(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req UpdateThirdPartyOrderNoRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "第三方单号不能为空且长度不能超过128位")
return
}
item, err := h.service.UpdateThirdPartyOrderNo(c.Request.Context(), id, req, adminID, auditMeta(c))
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, item)
}
// CreateFinancialAdjustment 为已完成提号创建利润或打款调整。
func (h *Handler) CreateFinancialAdjustment(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req FinancialAdjustmentRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "调整金额或原因不正确")
return
}
item, err := h.service.CreateFinancialAdjustment(c.Request.Context(), id, req, adminID, auditMeta(c))
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, item)
}
// ListFinancialAdjustments 返回提号完成后的财务调整历史。
func (h *Handler) ListFinancialAdjustments(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
items, err := h.service.ListFinancialAdjustments(c.Request.Context(), id)
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, items)
}
func (h *Handler) ProcessEvents(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
items, err := h.service.ProcessEvents(c.Request.Context(), id)
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
// SettleFinancialAdjustment 确认代管提号的补款或追回调整已线下处理。
func (h *Handler) SettleFinancialAdjustment(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req FinancialAdjustmentSettlementRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "确认备注格式不正确")
return
}
if err := h.service.SettleFinancialAdjustment(c.Request.Context(), id, req, adminID, auditMeta(c)); err != nil {
writePickupError(c, err)
return
}
response.OK(c, gin.H{"settled": true})
}
// 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)
}
// ShopOptions 返回后台提号已使用的店铺名称。
func (h *Handler) ShopOptions(c *gin.Context) {
items, err := h.service.ListShopNames(c.Request.Context(), c.Query("platform"))
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, items)
}
// 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) {
page, pageSize := parsePagination(c)
result, err := h.service.ListAvailableListings(c.Request.Context(), AvailableListingQuery{
Keyword: strings.TrimSpace(c.Query("keyword")),
SourceType: strings.TrimSpace(c.Query("source_type")),
Page: page,
PageSize: 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) {
response.RecordError(c, err)
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, "结算金额不正确")
case errors.Is(err, ErrInvalidProfit):
response.BadRequest(c, "利润金额不正确")
case errors.Is(err, ErrOfflineSettlementCannotMark):
response.BadRequest(c, "当前提号不可确认线下结算")
case errors.Is(err, ErrFinancialAdjustmentInvalid):
response.BadRequest(c, "调整金额、状态或原因不正确")
case errors.Is(err, ErrFinancialAdjustmentNotFound):
response.Error(c, http.StatusNotFound, "pickup_financial_adjustment_not_found", "财务调整记录不存在")
case errors.Is(err, ErrFinancialAdjustmentCannotSettle):
response.BadRequest(c, "当前调整不可确认处理")
case errors.Is(err, ErrPickupShopNotFound):
response.Error(c, http.StatusNotFound, "pickup_shop_not_found", "店铺不存在")
case errors.Is(err, ErrPickupShopNameExists):
response.Error(c, http.StatusConflict, "pickup_shop_name_exists", "店铺名称已存在")
case errors.Is(err, ErrPickupCancelled):
response.BadRequest(c, "提号订单已取消,不能补录单号")
case errors.Is(err, ErrInvalidThirdPartyOrderNo):
response.BadRequest(c, "第三方单号不能为空且长度不能超过128位")
default:
response.Error(c, http.StatusInternalServerError, "pickup_error", err.Error())
}
}
// ListShops 获取店铺配置列表(管理员)
func (h *Handler) ListShops(c *gin.Context) {
query := parsePickupShopQuery(c)
items, err := h.service.ListShops(c.Request.Context(), query)
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, items)
}
// CreateShop 创建店铺配置(管理员)
func (h *Handler) CreateShop(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreatePickupShopRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "店铺参数格式不正确")
return
}
item, err := h.service.CreateShop(c.Request.Context(), req, adminID, auditMeta(c))
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, item)
}
// UpdateShop 更新店铺配置(管理员)
func (h *Handler) UpdateShop(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
var req UpdatePickupShopRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "店铺参数格式不正确")
return
}
item, err := h.service.UpdateShop(c.Request.Context(), id, req, adminID, auditMeta(c))
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, item)
}
// DeleteShop 删除店铺配置(管理员)
func (h *Handler) DeleteShop(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
id, ok := parseID(c)
if !ok {
return
}
if err := h.service.DeleteShop(c.Request.Context(), id, adminID, auditMeta(c)); err != nil {
writePickupError(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
// GetHistoricalShopStats 获取历史店铺聚合统计及映射情况(管理员)
func (h *Handler) GetHistoricalShopStats(c *gin.Context) {
stats, err := h.service.GetHistoricalShopStats(c.Request.Context())
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, stats)
}
// MapHistoricalShops 批量映射历史店铺并清洗提号单(管理员)
func (h *Handler) MapHistoricalShops(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req MapHistoricalShopsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "映射参数格式不正确")
return
}
res, err := h.service.MapHistoricalShops(c.Request.Context(), req, adminID, auditMeta(c))
if err != nil {
writePickupError(c, err)
return
}
response.OK(c, res)
}
// DeleteShopMapping 删除历史店铺映射规则并还原提号单(管理员)
func (h *Handler) DeleteShopMapping(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
sourceName := c.Query("source_name")
if sourceName == "" {
response.BadRequest(c, "缺少 source_name 参数")
return
}
if err := h.service.DeleteShopMapping(c.Request.Context(), sourceName, adminID, auditMeta(c)); err != nil {
writePickupError(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
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,108 @@
package pickup
import (
"strconv"
"strings"
"time"
"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 parseFlexibleTime(val string) *time.Time {
val = strings.TrimSpace(val)
if val == "" {
return nil
}
layouts := []string{
"2006-01-02 15:04:05",
time.RFC3339,
"2006-01-02T15:04:05",
"2006-01-02",
}
loc := time.Local
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, val, loc); err == nil {
return &t
}
}
return nil
}
func parseAdminQuery(c *gin.Context) AdminPickupQuery {
page, pageSize := parsePagination(c)
return AdminPickupQuery{
Page: page,
PageSize: pageSize,
Keyword: c.Query("keyword"),
Status: c.Query("status"),
ShopName: c.Query("shop_name"),
AccountSource: c.Query("account_source"),
OfflineSettlementStatus: c.Query("offline_settlement_status"),
Operator: c.Query("operator"),
ThirdPartyOrderNo: c.Query("third_party_order_no"),
StartTime: parseFlexibleTime(c.Query("start_time")),
EndTime: parseFlexibleTime(c.Query("end_time")),
}
}
func parsePickupShopQuery(c *gin.Context) PickupShopQuery {
return PickupShopQuery{
Keyword: c.Query("keyword"),
Platform: c.Query("platform"),
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,76 @@
package pickup
import (
"context"
"encoding/json"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/processlog"
"gorm.io/datatypes"
"gorm.io/gorm"
)
const processBusinessPickup = "pickup"
type ProcessEventDTO struct {
ID uint64 `json:"id"`
Stage string `json:"stage"`
Action string `json:"action"`
ActorType string `json:"actor_type"`
ActorID uint64 `json:"actor_id"`
ActorName string `json:"actor_name"`
TargetType string `json:"target_type"`
TargetID *uint64 `json:"target_id,omitempty"`
TargetName string `json:"target_name"`
Content string `json:"content"`
Reason string `json:"reason"`
Payload datatypes.JSON `json:"payload"`
AttachmentURLs []string `json:"attachment_urls"`
StateBefore datatypes.JSON `json:"state_before"`
StateAfter datatypes.JSON `json:"state_after"`
CreatedAt string `json:"created_at"`
}
func pickupState(row model.AdminPickup) map[string]any {
return map[string]any{
"pickup_status": row.Status, "offline_settlement_status": row.OfflineSettlementStatus,
"settlement_mode": row.SettlementMode,
}
}
func appendPickupEvent(tx *gorm.DB, pickup model.AdminPickup, action string, adminID uint64, content, reason string, payload, before map[string]any) error {
return processlog.Append(tx, processlog.Entry{
BusinessType: processBusinessPickup, BusinessID: pickup.ID, Stage: "pickup", Action: action,
ActorType: processlog.ActorAdmin, ActorID: adminID, TargetType: processlog.ActorUser,
TargetID: &pickup.OwnerID, Content: content, Reason: reason, Payload: payload,
StateBefore: before, StateAfter: pickupState(pickup),
})
}
func (r *Repository) ProcessEvents(ctx context.Context, pickupID uint64) ([]ProcessEventDTO, error) {
var pickup model.AdminPickup
if err := r.db.WithContext(ctx).First(&pickup, pickupID).Error; err != nil {
return nil, err
}
rows, err := processlog.List(r.db.WithContext(ctx), processBusinessPickup, pickupID)
if err != nil {
return nil, err
}
items := make([]ProcessEventDTO, 0, len(rows))
for _, row := range rows {
items = append(items, ProcessEventDTO{
ID: row.ID, Stage: row.Stage, Action: row.Action, ActorType: row.ActorType, ActorID: row.ActorID,
ActorName: row.ActorName, TargetType: row.TargetType, TargetID: row.TargetID, TargetName: row.TargetName,
Content: row.Content, Reason: row.Reason, Payload: row.Payload, AttachmentURLs: decodeStringList(row.AttachmentURLs),
StateBefore: row.StateBefore, StateAfter: row.StateAfter, CreatedAt: row.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
return items, nil
}
func decodeStringList(raw datatypes.JSON) []string {
items := make([]string, 0)
_ = json.Unmarshal(raw, &items)
return items
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
package pickup
import (
"context"
"strings"
"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
}
if req.ProfitAmountCent < 0 {
return nil, ErrInvalidProfit
}
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
}
if req.ProfitAmountCent != nil && *req.ProfitAmountCent < 0 {
return nil, ErrInvalidProfit
}
return s.repo.Complete(ctx, pickupID, req, adminID, meta)
}
func (s *Service) MarkOfflineSettlement(ctx context.Context, pickupID uint64, req OfflineSettlementRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if pickupID == 0 {
return nil, ErrPickupNotFound
}
return s.repo.MarkOfflineSettlement(ctx, pickupID, req, adminID, meta)
}
func (s *Service) UpdateProfit(ctx context.Context, pickupID uint64, req UpdateProfitRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if pickupID == 0 {
return nil, ErrPickupNotFound
}
if req.ProfitAmountCent < 0 {
return nil, ErrInvalidProfit
}
return s.repo.UpdateProfit(ctx, pickupID, req, adminID, meta)
}
func (s *Service) UpdateThirdPartyOrderNo(ctx context.Context, pickupID uint64, req UpdateThirdPartyOrderNoRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if pickupID == 0 {
return nil, ErrPickupNotFound
}
if strings.TrimSpace(req.ThirdPartyOrderNo) == "" {
return nil, ErrInvalidThirdPartyOrderNo
}
return s.repo.UpdateThirdPartyOrderNo(ctx, pickupID, req, adminID, meta)
}
func (s *Service) CreateFinancialAdjustment(ctx context.Context, pickupID uint64, req FinancialAdjustmentRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if pickupID == 0 || (req.ProfitAmountCent == nil && req.SettleAmountCent == nil) || strings.TrimSpace(req.Reason) == "" {
return nil, ErrFinancialAdjustmentInvalid
}
if (req.ProfitAmountCent != nil && *req.ProfitAmountCent < 0) || (req.SettleAmountCent != nil && *req.SettleAmountCent < 0) {
return nil, ErrFinancialAdjustmentInvalid
}
return s.repo.CreateFinancialAdjustment(ctx, pickupID, req, adminID, meta)
}
func (s *Service) ListFinancialAdjustments(ctx context.Context, pickupID uint64) ([]FinancialAdjustmentDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
if pickupID == 0 {
return nil, ErrPickupNotFound
}
return s.repo.ListFinancialAdjustments(ctx, pickupID)
}
func (s *Service) ProcessEvents(ctx context.Context, pickupID uint64) ([]ProcessEventDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ProcessEvents(ctx, pickupID)
}
func (s *Service) SettleFinancialAdjustment(ctx context.Context, adjustmentID uint64, req FinancialAdjustmentSettlementRequest, adminID uint64, meta auditlog.Meta) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
if adjustmentID == 0 {
return ErrFinancialAdjustmentNotFound
}
return s.repo.SettleFinancialAdjustment(ctx, adjustmentID, 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) ListShopNames(ctx context.Context, platform string) ([]string, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListShopNames(ctx, platform)
}
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, query AvailableListingQuery) (*AvailableListingsResult, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListAvailableListings(ctx, query)
}
func (s *Service) ListShops(ctx context.Context, query PickupShopQuery) ([]PickupShopDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.ListShops(ctx, query)
}
func (s *Service) CreateShop(ctx context.Context, req CreatePickupShopRequest, adminID uint64, meta auditlog.Meta) (*PickupShopDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.CreateShop(ctx, req, adminID, meta)
}
func (s *Service) UpdateShop(ctx context.Context, id uint64, req UpdatePickupShopRequest, adminID uint64, meta auditlog.Meta) (*PickupShopDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.UpdateShop(ctx, id, req, adminID, meta)
}
func (s *Service) DeleteShop(ctx context.Context, id uint64, adminID uint64, meta auditlog.Meta) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.DeleteShop(ctx, id, adminID, meta)
}
func (s *Service) GetHistoricalShopStats(ctx context.Context) (*PickupShopHistoricalStatsDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.GetHistoricalShopStats(ctx)
}
func (s *Service) MapHistoricalShops(ctx context.Context, req MapHistoricalShopsRequest, adminID uint64, meta auditlog.Meta) (*MapHistoricalShopsResultDTO, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
return s.repo.MapHistoricalShops(ctx, req, adminID, meta)
}
func (s *Service) DeleteShopMapping(ctx context.Context, sourceName string, adminID uint64, meta auditlog.Meta) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.DeleteShopMapping(ctx, sourceName, adminID, meta)
}