From 944526d9ef173449bc40091b9a3113832317a4e7 Mon Sep 17 00:00:00 2001 From: Break Date: Mon, 1 Jun 2026 11:21:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=A2=E5=8D=95=E9=87=91?= =?UTF-8?q?=E9=A2=9D=E8=90=BD=E5=BA=93=E4=B8=8D=E5=AF=B9=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/admin/asset.go | 16 +++- internal/model/dto/asset_dto.go | 3 +- internal/model/order.go | 3 +- internal/model/package.go | 3 +- internal/service/asset/service.go | 20 ++++- internal/service/order/service.go | 62 +++++++------- internal/task/auto_purchase.go | 4 + ..._add_cost_price_and_retail_amount.down.sql | 6 ++ ...50_add_cost_price_and_retail_amount.up.sql | 12 +++ .../fix-order-price-semantics/.openspec.yaml | 2 + .../fix-order-price-semantics/design.md | 77 ++++++++++++++++++ .../fix-order-price-semantics/proposal.md | 41 ++++++++++ .../specs/admin-order-creation/spec.md | 79 ++++++++++++++++++ .../specs/client-order-purchase/spec.md | 40 +++++++++ .../spec.md | 81 +++++++++++++++++++ .../fix-order-price-semantics/tasks.md | 42 ++++++++++ 16 files changed, 454 insertions(+), 37 deletions(-) create mode 100644 migrations/000150_add_cost_price_and_retail_amount.down.sql create mode 100644 migrations/000150_add_cost_price_and_retail_amount.up.sql create mode 100644 openspec/changes/fix-order-price-semantics/.openspec.yaml create mode 100644 openspec/changes/fix-order-price-semantics/design.md create mode 100644 openspec/changes/fix-order-price-semantics/proposal.md create mode 100644 openspec/changes/fix-order-price-semantics/specs/admin-order-creation/spec.md create mode 100644 openspec/changes/fix-order-price-semantics/specs/client-order-purchase/spec.md create mode 100644 openspec/changes/fix-order-price-semantics/specs/package-usage-paid-amount-snapshot/spec.md create mode 100644 openspec/changes/fix-order-price-semantics/tasks.md diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index a211048..509ef36 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -136,7 +136,8 @@ func (h *AssetHandler) Packages(c *fiber.Ctx) error { status = &parsedStatus } - result, err := h.assetService.GetPackages(c.UserContext(), asset.AssetType, asset.AssetID, page, pageSize, status) + callerAccountType := resolveCallerAccountType(c) + result, err := h.assetService.GetPackages(c.UserContext(), asset.AssetType, asset.AssetID, page, pageSize, status, callerAccountType) if err != nil { return err } @@ -152,7 +153,8 @@ func (h *AssetHandler) CurrentPackage(c *fiber.Ctx) error { return err } - result, err := h.assetService.GetCurrentPackage(c.UserContext(), asset.AssetType, asset.AssetID) + callerAccountType := resolveCallerAccountType(c) + result, err := h.assetService.GetCurrentPackage(c.UserContext(), asset.AssetType, asset.AssetID, callerAccountType) if err != nil { return err } @@ -428,3 +430,13 @@ func (h *AssetHandler) UpdateRealnameStatus(c *fiber.Ctx) error { FirstRealnameAt: card.FirstRealnameAt, }) } + +// resolveCallerAccountType 从 middleware 上下文中提取调用方账号类型 +// 平台账号(SuperAdmin/Platform)返回 "platform",其他返回 "agent" +func resolveCallerAccountType(c *fiber.Ctx) string { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform { + return constants.OwnerTypePlatform + } + return "agent" +} diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index 4048403..c4316f7 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -135,7 +135,8 @@ type AssetPackageResponse struct { ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间(待生效套餐为空)"` MasterUsageID *uint `json:"master_usage_id,omitempty" description:"主套餐ID(加油包时有值)"` Priority int `json:"priority" description:"优先级"` - PaidAmount *int64 `json:"paid_amount,omitempty" description:"购买实付金额(分),线下支付或无订单分配时为空"` + PaidAmount *int64 `json:"paid_amount,omitempty" description:"购买成本价(分),仅平台账号可见,非平台账号不返回此字段"` + RetailAmount *int64 `json:"retail_amount,omitempty" description:"购买零售价(分),无订单关联时为空"` CreatedAt time.Time `json:"created_at" description:"创建时间"` } diff --git a/internal/model/order.go b/internal/model/order.go index 5d888ed..f296b59 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -152,7 +152,8 @@ type OrderItem struct { PackageName string `gorm:"column:package_name;type:varchar(100);not null;comment:套餐名称(快照)" json:"package_name"` PackageType *string `gorm:"column:package_type;type:varchar(20);comment:套餐类型快照 formal-正式套餐 addon-加油包" json:"package_type"` Quantity int `gorm:"column:quantity;type:int;default:1;not null;comment:数量" json:"quantity"` - UnitPrice int64 `gorm:"column:unit_price;type:bigint;not null;comment:单价(分)" json:"unit_price"` + UnitPrice int64 `gorm:"column:unit_price;type:bigint;not null;comment:零售单价快照(分,客户面值)" json:"unit_price"` + CostPrice int64 `gorm:"column:cost_price;type:bigint;not null;default:0;comment:成本单价快照(分,代购/钱包支付场景为买家成本,平台自营或赠送时为0)" json:"cost_price"` Amount int64 `gorm:"column:amount;type:bigint;not null;comment:小计金额(分)" json:"amount"` PackagePriceConfigStatus int `gorm:"column:package_price_config_status;type:int;default:0;not null;comment:套餐价格配置状态快照 0-未配置 1-赠送0价 2-已配置非0" json:"package_price_config_status"` PackageIsGift bool `gorm:"column:package_is_gift;type:boolean;default:false;not null;comment:套餐是否赠送快照 true-赠送 false-普通可售" json:"package_is_gift"` diff --git a/internal/model/package.go b/internal/model/package.go index ccf5d4d..8c68655 100644 --- a/internal/model/package.go +++ b/internal/model/package.go @@ -84,7 +84,8 @@ type PackageUsage struct { HasIndependentExpiry bool `gorm:"column:has_independent_expiry;type:boolean;default:false;comment:加油包是否有独立有效期(true-有独立到期时间 false-跟随主套餐)" json:"has_independent_expiry"` PendingRealnameActivation bool `gorm:"column:pending_realname_activation;type:boolean;default:false;comment:是否等待实名激活(true-待实名后激活 false-已激活或不需实名)" json:"pending_realname_activation"` PackageName string `gorm:"column:package_name;type:varchar(255);not null;default:'';comment:套餐名称快照(从Package复制,用于历史记录展示)" json:"package_name"` - PaidAmount *int64 `gorm:"column:paid_amount;type:bigint;comment:购买实付金额快照(分,从订单复制,无订单或线下支付时为null)" json:"paid_amount,omitempty"` + PaidAmount *int64 `gorm:"column:paid_amount;type:bigint;comment:购买实付金额快照(分,从订单 actual_paid_amount 复制,无订单或线下支付时为null)" json:"paid_amount,omitempty"` + RetailAmount *int64 `gorm:"column:retail_amount;type:bigint;comment:购买零售价快照(分,从订单 total_amount 复制,无订单关联时为null)" json:"retail_amount,omitempty"` PackagePriceConfigStatus int `gorm:"column:package_price_config_status;type:int;default:0;not null;comment:套餐价格配置状态快照 0-未配置 1-赠送0价 2-已配置非0" json:"package_price_config_status"` PackageIsGift bool `gorm:"column:package_is_gift;type:boolean;default:false;not null;comment:套餐是否赠送快照 true-赠送 false-普通可售" json:"package_is_gift"` DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);comment:流量重置周期(从Package复制,用于历史记录)" json:"data_reset_cycle"` diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 51aa13f..2cf12a4 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -696,8 +696,9 @@ func parseGatewayTime(raw gateway.FlexString) *time.Time { } // GetPackages 获取资产的所有套餐列表(支持分页和状态筛选) +// callerAccountType: 调用方账号类型,"platform" 时返回成本价(paid_amount),其他类型不返回 // page 默认 1,pageSize 默认 50,pageSize 最大 100 -func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, page, pageSize int, status *int) (*dto.AssetPackagesResult, error) { +func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, page, pageSize int, status *int, callerAccountType string) (*dto.AssetPackagesResult, error) { // 分页参数边界处理 if page < 1 { page = 1 @@ -757,6 +758,10 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa pkgType = pkg.PackageType expiryBase = pkg.ExpiryBase } + var paidAmount *int64 + if callerAccountType == constants.OwnerTypePlatform { + paidAmount = u.PaidAmount + } item := &dto.AssetPackageResponse{ PackageUsageID: u.ID, PackageID: u.PackageID, @@ -780,7 +785,8 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa ExpiresAt: u.ExpiresAt, MasterUsageID: u.MasterUsageID, Priority: u.Priority, - PaidAmount: u.PaidAmount, + PaidAmount: paidAmount, + RetailAmount: u.RetailAmount, CreatedAt: u.CreatedAt, } all = append(all, item) @@ -811,7 +817,8 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa } // GetCurrentPackage 获取资产当前生效的主套餐 -func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id uint) (*dto.AssetPackageResponse, error) { +// callerAccountType: 调用方账号类型,"platform" 时返回成本价(paid_amount),其他类型不返回 +func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id uint, callerAccountType string) (*dto.AssetPackageResponse, error) { carrierType := assetType if assetType == "card" { carrierType = "iot_card" @@ -839,6 +846,10 @@ func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id ui pkgType = pkg.PackageType expiryBase = pkg.ExpiryBase } + var paidAmount *int64 + if callerAccountType == constants.OwnerTypePlatform { + paidAmount = usage.PaidAmount + } return &dto.AssetPackageResponse{ PackageUsageID: usage.ID, PackageID: usage.PackageID, @@ -862,7 +873,8 @@ func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id ui ExpiresAt: usage.ExpiresAt, MasterUsageID: usage.MasterUsageID, Priority: usage.Priority, - PaidAmount: usage.PaidAmount, + PaidAmount: paidAmount, + RetailAmount: usage.RetailAmount, CreatedAt: usage.CreatedAt, }, nil } diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 218c15f..1f18687 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -242,6 +242,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde purchaseRole := "" var sellerShopID *uint = resourceShopID var sellerCostPrice int64 + var itemCostPriceMap map[uint]int64 // 各套餐成本单价映射,用于 OrderItem.CostPrice 字段 // 根据支付方式分别处理 if req.PaymentMethod == model.PaymentMethodOffline { @@ -249,7 +250,6 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde if containsGift { orderBuyerType = model.BuyerTypePersonal orderBuyerID = 0 - totalAmount = 0 paymentMethod = model.PaymentMethodOffline paymentStatus = model.PaymentStatusPaid paidAt = &now @@ -286,8 +286,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde } orderBuyerType = model.BuyerTypeAgent orderBuyerID = purchaseBuyerID - itemUnitPriceMap = buyerCostPriceMap - totalAmount = buyerTotalCost + itemCostPriceMap = buyerCostPriceMap paymentMethod = model.PaymentMethodOffline paymentStatus = model.PaymentStatusPaid paidAt = purchasePaidAt @@ -298,7 +297,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde operatorID = nil operatorType = constants.OwnerTypePlatform purchaseRole = model.PurchaseRolePurchasedByPlatform - actualPaidAmount = nil + actualPaidAmount = &buyerTotalCost } } @@ -320,8 +319,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde orderBuyerType = model.BuyerTypeAgent orderBuyerID = operatorShopID - itemUnitPriceMap = operatorCostPriceMap - totalAmount = operatorTotalCost + itemCostPriceMap = operatorCostPriceMap paymentMethod = model.PaymentMethodWallet paymentStatus = model.PaymentStatusPaid paidAt = &now @@ -336,13 +334,13 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde } else { // ==== 子场景 2.2:代理代购(给下级购买)==== - // 获取买家成本价 - buyerCostPriceMap, buyerTotalCost, err := s.buildCostPriceMap(ctx, validationResult.Packages, *resourceShopID) + // 获取买家成本价(买家的价格映射用于 CostPrice 快照,总价不再使用) + buyerCostPriceMap, _, err := s.buildCostPriceMap(ctx, validationResult.Packages, *resourceShopID) if err != nil { return nil, err } - // 获取操作者成本价 + // 获取操作者成本价(操作方实际扣款金额和佣金基准) _, operatorTotalCost, err := s.buildCostPriceMap(ctx, validationResult.Packages, operatorShopID) if err != nil { return nil, err @@ -350,8 +348,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde orderBuyerType = model.BuyerTypeAgent orderBuyerID = *resourceShopID - itemUnitPriceMap = buyerCostPriceMap - totalAmount = buyerTotalCost + itemCostPriceMap = buyerCostPriceMap paymentMethod = model.PaymentMethodWallet paymentStatus = model.PaymentStatusPaid paidAt = &now @@ -375,8 +372,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde orderBuyerType = model.BuyerTypeAgent orderBuyerID = targetShopID - itemUnitPriceMap = targetCostPriceMap - totalAmount = targetTotalCost + itemCostPriceMap = targetCostPriceMap paymentMethod = model.PaymentMethodWallet paymentStatus = model.PaymentStatusPaid paidAt = &now @@ -465,7 +461,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde order.BuyerPhone, order.BuyerNickname = s.fetchBuyerSnapshot(ctx, orderBuyerID) } - items := s.buildOrderItems(userID, validationResult.Packages, itemUnitPriceMap) + items := s.buildOrderItems(userID, validationResult.Packages, itemUnitPriceMap, itemCostPriceMap) idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs) @@ -606,7 +602,8 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest purchaseRole := "" var sellerShopID *uint = resourceShopID var sellerCostPrice int64 - var expiresAt *time.Time // 待支付订单设置过期时间,立即支付的订单为 nil + var expiresAt *time.Time // 待支付订单设置过期时间,立即支付的订单为 nil + var itemCostPriceMap map[uint]int64 // 各套餐成本单价映射,用于 OrderItem.CostPrice 字段 // 场景判断:offline(平台代购)、wallet(代理钱包支付)、其他(待支付) if req.PaymentMethod == model.PaymentMethodOffline { @@ -617,8 +614,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest } orderBuyerType = model.BuyerTypeAgent orderBuyerID = purchaseBuyerID - itemUnitPriceMap = buyerCostPriceMap - totalAmount = buyerTotalCost + itemCostPriceMap = buyerCostPriceMap paymentMethod = model.PaymentMethodOffline paymentStatus = model.PaymentStatusPaid paidAt = purchasePaidAt @@ -629,7 +625,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest operatorID = nil operatorType = constants.OwnerTypePlatform purchaseRole = model.PurchaseRolePurchasedByPlatform - actualPaidAmount = nil + actualPaidAmount = &buyerTotalCost } else if req.PaymentMethod == model.PaymentMethodWallet { // ==== 场景 2:代理钱包支付(wallet)==== @@ -653,8 +649,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest orderBuyerType = model.BuyerTypeAgent orderBuyerID = operatorShopID - itemUnitPriceMap = operatorCostPriceMap - totalAmount = operatorTotalCost + itemCostPriceMap = operatorCostPriceMap paymentMethod = model.PaymentMethodWallet paymentStatus = model.PaymentStatusPaid paidAt = &now @@ -669,13 +664,13 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest } else { // ==== 子场景 2.2:代理代购(给下级购买)==== - // 获取买家成本价 - buyerCostPriceMap, buyerTotalCost, err := s.buildCostPriceMap(ctx, validationResult.Packages, *resourceShopID) + // 获取买家成本价(价格映射用于 CostPrice 快照,总价不再使用) + buyerCostPriceMap, _, err := s.buildCostPriceMap(ctx, validationResult.Packages, *resourceShopID) if err != nil { return nil, err } - // 获取操作者成本价 + // 获取操作者成本价(操作方实际扣款金额和佣金基准) _, operatorTotalCost, err := s.buildCostPriceMap(ctx, validationResult.Packages, operatorShopID) if err != nil { return nil, err @@ -683,8 +678,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest orderBuyerType = model.BuyerTypeAgent orderBuyerID = *resourceShopID - itemUnitPriceMap = buyerCostPriceMap - totalAmount = buyerTotalCost + itemCostPriceMap = buyerCostPriceMap paymentMethod = model.PaymentMethodWallet paymentStatus = model.PaymentStatusPaid paidAt = &now @@ -750,7 +744,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest PaymentConfigID: h5PaymentConfigID, } - items := s.buildOrderItems(userID, validationResult.Packages, itemUnitPriceMap) + items := s.buildOrderItems(userID, validationResult.Packages, itemUnitPriceMap, itemCostPriceMap) idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs) @@ -824,7 +818,9 @@ func (s *Service) resolvePurchaseOnBehalfInfo(ctx context.Context, result *purch return *resourceShopID, buyerCostPriceMap, buyerTotalCost, &now, nil } -func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package, unitPriceMap map[uint]int64) []*model.OrderItem { +// buildOrderItems 构建订单明细列表 +// retailPriceMap: 零售单价映射(UnitPrice),costPriceMap: 成本单价映射(CostPrice,无成本价时传 nil) +func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package, retailPriceMap map[uint]int64, costPriceMap map[uint]int64) []*model.OrderItem { items := make([]*model.OrderItem, 0, len(packages)) for _, pkg := range packages { if pkg == nil { @@ -832,10 +828,15 @@ func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package, un } unitPrice := packageprice.PackageEffectiveRetailPrice(pkg) - if price, ok := unitPriceMap[pkg.ID]; ok { + if price, ok := retailPriceMap[pkg.ID]; ok { unitPrice = price } + var costPrice int64 + if costPriceMap != nil { + costPrice = costPriceMap[pkg.ID] + } + item := &model.OrderItem{ BaseModel: model.BaseModel{ Creator: operatorID, @@ -846,6 +847,7 @@ func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package, un PackageType: &pkg.PackageType, Quantity: 1, UnitPrice: unitPrice, + CostPrice: costPrice, Amount: unitPrice, PackagePriceConfigStatus: pkg.PriceConfigStatus, PackageIsGift: pkg.IsGift, @@ -2295,6 +2297,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m // 创建套餐使用记录 virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg) + retailAmount := order.TotalAmount usage := &model.PackageUsage{ BaseModel: model.BaseModel{ Creator: order.Creator, @@ -2314,6 +2317,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m DataResetCycle: pkg.DataResetCycle, PendingRealnameActivation: pendingRealnameActivation, PaidAmount: order.ActualPaidAmount, + RetailAmount: &retailAmount, PackagePriceConfigStatus: pkg.PriceConfigStatus, PackageIsGift: pkg.IsGift, } @@ -2380,6 +2384,7 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order * } virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg) + addonRetailAmount := order.TotalAmount usage := &model.PackageUsage{ BaseModel: model.BaseModel{ Creator: order.Creator, @@ -2401,6 +2406,7 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order * ExpiresAt: &expiresAt, DataResetCycle: pkg.DataResetCycle, PaidAmount: order.ActualPaidAmount, + RetailAmount: &addonRetailAmount, PackagePriceConfigStatus: pkg.PriceConfigStatus, PackageIsGift: pkg.IsGift, } diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index b78da15..0deb1db 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -532,6 +532,7 @@ func (h *AutoPurchaseHandler) activateMainPackage( // ExpiryBase 仅影响后台囤货路径,此处无需判断 virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg) + retailAmount := order.TotalAmount usage := &model.PackageUsage{ BaseModel: model.BaseModel{ Creator: order.Creator, @@ -552,6 +553,7 @@ func (h *AutoPurchaseHandler) activateMainPackage( PendingRealnameActivation: pendingRealnameActivation, Generation: order.Generation, PaidAmount: order.ActualPaidAmount, + RetailAmount: &retailAmount, PackagePriceConfigStatus: pkg.PriceConfigStatus, PackageIsGift: pkg.IsGift, } @@ -606,6 +608,7 @@ func (h *AutoPurchaseHandler) activateAddonPackage( expiresAt := mainPackage.ExpiresAt virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg) + addonRetailAmount := order.TotalAmount usage := &model.PackageUsage{ BaseModel: model.BaseModel{ Creator: order.Creator, @@ -628,6 +631,7 @@ func (h *AutoPurchaseHandler) activateAddonPackage( DataResetCycle: pkg.DataResetCycle, Generation: order.Generation, PaidAmount: order.ActualPaidAmount, + RetailAmount: &addonRetailAmount, PackagePriceConfigStatus: pkg.PriceConfigStatus, PackageIsGift: pkg.IsGift, } diff --git a/migrations/000150_add_cost_price_and_retail_amount.down.sql b/migrations/000150_add_cost_price_and_retail_amount.down.sql new file mode 100644 index 0000000..6f6c077 --- /dev/null +++ b/migrations/000150_add_cost_price_and_retail_amount.down.sql @@ -0,0 +1,6 @@ +-- 回滚:删除新增字段 +ALTER TABLE tb_order_item + DROP COLUMN IF EXISTS cost_price; + +ALTER TABLE tb_package_usage + DROP COLUMN IF EXISTS retail_amount; diff --git a/migrations/000150_add_cost_price_and_retail_amount.up.sql b/migrations/000150_add_cost_price_and_retail_amount.up.sql new file mode 100644 index 0000000..261f95c --- /dev/null +++ b/migrations/000150_add_cost_price_and_retail_amount.up.sql @@ -0,0 +1,12 @@ +-- 修正订单价格语义:新增成本价字段和零售价快照字段 +-- tb_order_item 新增 cost_price:存储成本单价快照,unit_price 语义修正为零售单价 +ALTER TABLE tb_order_item + ADD COLUMN IF NOT EXISTS cost_price BIGINT NOT NULL DEFAULT 0; + +COMMENT ON COLUMN tb_order_item.cost_price IS '成本单价快照(分,代购/钱包支付场景为买家成本,平台自营或赠送时为0)'; + +-- tb_package_usage 新增 retail_amount:存储零售价快照,paid_amount 继续存储成本价 +ALTER TABLE tb_package_usage + ADD COLUMN IF NOT EXISTS retail_amount BIGINT; + +COMMENT ON COLUMN tb_package_usage.retail_amount IS '购买零售价快照(分,从订单 total_amount 复制,无订单关联时为null)'; diff --git a/openspec/changes/fix-order-price-semantics/.openspec.yaml b/openspec/changes/fix-order-price-semantics/.openspec.yaml new file mode 100644 index 0000000..a2168c3 --- /dev/null +++ b/openspec/changes/fix-order-price-semantics/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-01 diff --git a/openspec/changes/fix-order-price-semantics/design.md b/openspec/changes/fix-order-price-semantics/design.md new file mode 100644 index 0000000..8623e23 --- /dev/null +++ b/openspec/changes/fix-order-price-semantics/design.md @@ -0,0 +1,77 @@ +## Context + +当前 `tb_order.total_amount` 在后台代购/钱包支付场景被覆盖为成本价,导致: +1. C 端订单列表/详情展示成本价(甚至 0 元) +2. `tb_package_usage.paid_amount` 快照的是成本价,资产套餐接口对所有后台账号一视同仁地暴露成本价 + +字段语义混乱的根源:`total_amount` 在 admin order service 中被 `buyerTotalCost` 覆盖,`retailTotalAmount` 计算出来后被丢弃。`actual_paid_amount` 本应是成本价,但 `total_amount` 也是成本价,两者语义重叠。 + +涉及模块:`internal/service/order`、`internal/service/client_order`、`internal/service/asset`、`internal/task`、`internal/model`、`internal/handler/admin`。 + +## Goals / Non-Goals + +**Goals:** +- `Order.TotalAmount` 始终 = 零售价(客户面值),C 端和后台均可安全展示 +- `Order.ActualPaidAmount` = 实际支付成本价,钱包扣款以此为准 +- `OrderItem.UnitPrice` = 零售单价,新增 `CostPrice` 字段存成本单价 +- `PackageUsage.RetailAmount` 快照零售价,`PaidAmount` 继续快照成本价 +- 资产套餐接口按调用方账号类型过滤:非平台账号不返回 `paid_amount` + +**Non-Goals:** +- 不修改 C 端下单流程(C 端本来就用零售价,无需改动) +- 不修改佣金计算逻辑(佣金基于 `SellerCostPrice`,不受此次影响) +- 不修改退款逻辑(退款金额基于 `ActualPaidAmount`,语义已正确) +- 不重命名 `PaidAmount` 字段(保持向后兼容) + +## Decisions + +### 决策 1:`TotalAmount` 始终存零售价,`ActualPaidAmount` 存成本价 + +**选择**:admin order service 中 `totalAmount` 始终赋值 `retailTotalAmount`,不再被 `buyerTotalCost` 覆盖;成本价只写入 `ActualPaidAmount`。 + +**理由**:`TotalAmount` 是订单的"面值",对客户可见;`ActualPaidAmount` 是"实付",是内部财务字段。两者语义本来就应该分离,当前代码是历史遗留的语义混用。 + +**钱包扣款影响**:`createOrderWithWalletPayment` 目前用 `order.TotalAmount` 扣款,改为用 `order.ActualPaidAmount`。这是正确的——代理扣的是成本价,不是零售价。 + +**替代方案**:新增独立的 `RetailAmount` 字段存零售价,`TotalAmount` 保持现状。被否决,因为 `TotalAmount` 的字段名本身就暗示"订单总金额",应该是客户看到的面值,改语义比加字段更干净。 + +### 决策 2:`OrderItem` 新增 `CostPrice` 字段 + +**选择**:`tb_order_item` 新增 `cost_price BIGINT NOT NULL DEFAULT 0`,`UnitPrice` 改为存零售单价。 + +**理由**:详情页需要展示每个套餐的单价,C 端看零售价,后台平台账号可能需要看成本价做对账。两个字段各司其职,不互相覆盖。 + +**替代方案**:只在 order 级别存成本价(`SellerCostPrice` 已有),item 级别不存。被否决,因为多套餐订单时无法还原每个套餐的成本单价。 + +### 决策 3:`PackageUsage` 新增 `RetailAmount` 字段,`PaidAmount` 保持不变 + +**选择**:新增 `retail_amount BIGINT NULLABLE`,来源为 `order.TotalAmount`(零售价);`PaidAmount` 继续来源于 `order.ActualPaidAmount`(成本价)。 + +**理由**:`PaidAmount` 已有存量数据和下游依赖,重命名风险高。新增字段向后兼容,存量数据通过迁移 SQL 回填。 + +### 决策 4:资产套餐接口按账号类型过滤,在 Service 层实现 + +**选择**:`GetPackages` / `GetCurrentPackage` 新增 `callerAccountType string` 参数,Service 层根据此参数决定是否填充 `PaidAmount`;Handler 层从 middleware 读取账号类型后传入。 + +**理由**:过滤逻辑是业务规则,属于 Service 层职责。Handler 层只负责提取调用方身份并传递,不做业务判断。 + +**账号类型判断规则**:`callerAccountType == "platform"` 时返回 `PaidAmount`,其他类型(`agent`、`enterprise`、`personal_customer`)不返回。 + +## Risks / Trade-offs + +- **[风险] 钱包扣款金额变化**:改用 `ActualPaidAmount` 扣款后,代理自购场景扣款金额不变(成本价),但需确认所有扣款路径都已切换,避免遗漏。→ 迁移时逐一检查 `createOrderWithWalletPayment` 的所有调用点。 + +- **[风险] 存量订单数据不一致**:历史订单的 `TotalAmount` 已经是成本价,无法回填零售价(零售价快照未存储)。→ 接受此历史数据问题,仅对新订单生效;C 端展示历史订单时可能仍显示成本价,属于已知限制。 + +- **[风险] `PackageUsage.RetailAmount` 存量数据为 null**:历史套餐使用记录无零售价快照。→ 迁移 SQL 尝试从关联订单回填,但历史订单 `TotalAmount` 已是成本价,回填值不准确。接受此限制,存量数据 `RetailAmount` 保持 null,接口返回时 omitempty 处理。 + +- **[Trade-off] 赠送套餐(0 元)场景**:`TotalAmount` = 零售价,`ActualPaidAmount` = 0。C 端看到零售价,后台看到实付 0 元。这是期望行为(已与业务确认)。 + +## Migration Plan + +1. 执行数据库迁移:`tb_order_item` 加 `cost_price`,`tb_package_usage` 加 `retail_amount` +2. 部署新代码(新订单开始写入正确的字段语义) +3. 存量数据回填(`retail_amount` 从关联订单 `total_amount` 回填,已知不准确,仅作参考) +4. 无需回滚策略:新增字段有默认值,旧代码不读新字段,可安全回滚 + +**回滚**:新字段有 `DEFAULT 0` / `NULLABLE`,回滚旧代码后新字段被忽略,不影响业务。 diff --git a/openspec/changes/fix-order-price-semantics/proposal.md b/openspec/changes/fix-order-price-semantics/proposal.md new file mode 100644 index 0000000..480ae77 --- /dev/null +++ b/openspec/changes/fix-order-price-semantics/proposal.md @@ -0,0 +1,41 @@ +## Why + +后台代购/钱包支付场景下,`Order.TotalAmount` 被覆盖为成本价,导致 C 端订单列表和详情展示的是成本价(甚至 0 元),而非客户应看到的零售售价。同时,`/api/admin/assets/{identifier}/packages` 和 `/api/admin/assets/{identifier}/current-package` 两个接口的套餐价格字段(`paid_amount`)对所有后台账号一视同仁地返回成本价,非平台账号(代理商)不应看到平台进货成本。 + +## What Changes + +- **修正 `Order.TotalAmount` 语义**:始终存储零售价(客户面值),不再被成本价覆盖 +- **修正 `Order.ActualPaidAmount` 语义**:存储实际支付的成本价,钱包扣款改用此字段 +- **新增 `OrderItem.CostPrice` 字段**:在 `tb_order_item` 存储成本单价快照,零售单价保留在 `UnitPrice` +- **新增 `PackageUsage.RetailAmount` 字段**:在 `tb_package_usage` 快照零售价,来源为 `order.TotalAmount`;现有 `PaidAmount` 继续快照成本价(来源为 `order.ActualPaidAmount`) +- **资产套餐接口按角色过滤**:`/packages` 和 `/current-package` 对非平台账号隐藏 `paid_amount`(成本价),仅返回 `retail_amount`(零售价);平台账号两个字段均返回 + +## Capabilities + +### New Capabilities + +无新增 Capability。 + +### Modified Capabilities + +- `admin-order-creation`:`TotalAmount` 语义修正为零售价;新增 `OrderItem.CostPrice` 字段;钱包扣款改用 `ActualPaidAmount` +- `client-order-purchase`:C 端订单列表/详情的 `total_amount` 和套餐 `price` 字段改为展示零售价 +- `package-usage-paid-amount-snapshot`:新增 `RetailAmount` 字段快照零售价;`PaidAmount` 继续快照成本价;资产套餐接口按调用方角色决定是否返回 `paid_amount` + +## Impact + +**数据库**: +- `tb_order_item` 新增 `cost_price BIGINT NOT NULL DEFAULT 0` +- `tb_package_usage` 新增 `retail_amount BIGINT NULLABLE` + +**代码**: +- `internal/service/order/service.go`:admin 下单逻辑中 `totalAmount` 始终用零售价,成本价只写 `ActualPaidAmount` 和 `OrderItem.CostPrice`;钱包扣款改用 `ActualPaidAmount` +- `internal/service/order/service.go`:套餐激活时同步写入 `PackageUsage.RetailAmount` +- `internal/task/auto_purchase.go`:同步写入 `PackageUsage.RetailAmount` +- `internal/service/asset/service.go`:`GetPackages` / `GetCurrentPackage` 接收调用方账号类型参数,按角色决定是否返回 `paid_amount` +- `internal/handler/admin/asset.go`:传入调用方账号类型 +- `internal/model/dto/asset_dto.go`:`AssetPackageResponse` 新增 `RetailAmount` 字段 +- `internal/model/order.go`:`OrderItem` 新增 `CostPrice` 字段 +- `internal/model/package.go`:`PackageUsage` 新增 `RetailAmount` 字段 + +**迁移**:需要两条迁移 SQL 回填存量数据 diff --git a/openspec/changes/fix-order-price-semantics/specs/admin-order-creation/spec.md b/openspec/changes/fix-order-price-semantics/specs/admin-order-creation/spec.md new file mode 100644 index 0000000..b8a3df5 --- /dev/null +++ b/openspec/changes/fix-order-price-semantics/specs/admin-order-creation/spec.md @@ -0,0 +1,79 @@ +## MODIFIED Requirements + +### Requirement: 后台订单 TotalAmount 语义修正 + +系统 SHALL 在后台创建订单时,`Order.TotalAmount` MUST 始终存储零售价(客户面值),不得被成本价覆盖。`Order.ActualPaidAmount` MUST 存储实际支付的成本价。 + +**字段语义规范**: +- `total_amount`:零售价,所有场景均为套餐零售单价之和,C 端和后台均可安全展示 +- `actual_paid_amount`:实付成本价,代购/钱包支付场景为买家实际支付的成本金额 + +**各场景赋值规则**: + +| 场景 | total_amount | actual_paid_amount | +|------|-------------|-------------------| +| 平台自营 offline | 零售价 | 零售价(无差价) | +| 平台代购 offline | 零售价 | 成本价(买家成本) | +| 代理自购 wallet | 零售价 | 成本价(自己的成本价) | +| 代理代购 wallet | 零售价 | 操作方成本价 | +| 赠送套餐 offline | 零售价 | 0 | + +**钱包扣款**:`createOrderWithWalletPayment` MUST 使用 `order.ActualPaidAmount` 作为扣款金额,不得使用 `order.TotalAmount`。 + +#### Scenario: 平台代购时 TotalAmount 为零售价 + +- **WHEN** 平台为代理商下属资产创建 offline 订单,套餐零售价 9900 分,代理成本价 7000 分 +- **THEN** `order.total_amount = 9900`,`order.actual_paid_amount = 7000` + +#### Scenario: 代理自购时 TotalAmount 为零售价 + +- **WHEN** 代理商以 wallet 支付为自己的资产购买套餐,套餐零售价 9900 分,代理成本价 7000 分 +- **THEN** `order.total_amount = 9900`,`order.actual_paid_amount = 7000`,钱包扣款 7000 分 + +#### Scenario: 代理代购时 TotalAmount 为零售价 + +- **WHEN** 上级代理以 wallet 支付为下级代理资产购买套餐,零售价 9900 分,操作方成本价 8000 分,买家成本价 7000 分 +- **THEN** `order.total_amount = 9900`,`order.actual_paid_amount = 8000`(操作方实付),钱包扣款 8000 分 + +#### Scenario: 赠送套餐时 TotalAmount 为零售价 + +- **WHEN** 平台以 offline 方式赠送套餐(含赠送标记),套餐零售价 9900 分 +- **THEN** `order.total_amount = 9900`,`order.actual_paid_amount = 0` + +--- + +### Requirement: OrderItem 新增 CostPrice 字段 + +系统 SHALL 在 `tb_order_item` 中新增 `cost_price BIGINT NOT NULL DEFAULT 0` 字段,存储套餐成本单价快照。`unit_price` 字段 MUST 改为存储零售单价。 + +**字段规范**: +- `unit_price`:零售单价(分),所有场景均为套餐对外售价 +- `cost_price`:成本单价(分),代购/钱包支付场景为买家实际成本;平台自营或赠送时为 0 + +**赋值来源**:`cost_price` 从 `itemUnitPriceMap`(成本价 map)中取值;`unit_price` 从零售价 map 中取值。 + +#### Scenario: 代理自购时 OrderItem 同时记录两种价格 + +- **WHEN** 代理商购买套餐,零售价 9900 分,成本价 7000 分 +- **THEN** `order_item.unit_price = 9900`,`order_item.cost_price = 7000` + +#### Scenario: 平台自营时 CostPrice 为 0 + +- **WHEN** 平台以 offline 方式为平台自营资产购买套餐,零售价 9900 分 +- **THEN** `order_item.unit_price = 9900`,`order_item.cost_price = 0` + +#### Scenario: 赠送套餐时 CostPrice 为 0 + +- **WHEN** 平台赠送套餐,零售价 9900 分 +- **THEN** `order_item.unit_price = 9900`,`order_item.cost_price = 0` + +--- + +### Requirement: 后台订单 API 响应格式更新 + +系统 SHALL 在后台订单创建响应中,`total_amount` 字段返回零售价,`actual_paid_amount` 字段返回成本价。 + +#### Scenario: wallet 订单响应 total_amount 为零售价 + +- **WHEN** 代理在后台创建 wallet 订单成功,零售价 9900 分,成本价 7000 分 +- **THEN** 响应中 `total_amount = 9900`,`actual_paid_amount = 7000` diff --git a/openspec/changes/fix-order-price-semantics/specs/client-order-purchase/spec.md b/openspec/changes/fix-order-price-semantics/specs/client-order-purchase/spec.md new file mode 100644 index 0000000..39b1a9e --- /dev/null +++ b/openspec/changes/fix-order-price-semantics/specs/client-order-purchase/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: D2 套餐订单列表接口价格展示 + +系统 SHALL 在 C 端订单列表接口中,`total_amount` 字段 MUST 返回零售价(客户面值)。后台代购/钱包支付场景下,C 端客户看到的是套餐零售价,而非成本价。 + +由于 `Order.TotalAmount` 语义已修正为零售价(见 `admin-order-creation` 变更),C 端列表接口无需修改代码,直接受益于数据层修正。 + +#### Scenario: 后台代购订单在 C 端展示零售价 + +- **WHEN** 平台为客户代购套餐(成本价 7000 分,零售价 9900 分),客户查询 C 端订单列表 +- **THEN** 列表项 `total_amount = 9900`,展示零售价 + +#### Scenario: 代理钱包支付订单在 C 端展示零售价 + +- **WHEN** 代理以钱包支付为客户购买套餐(成本价 7000 分,零售价 9900 分),客户查询 C 端订单列表 +- **THEN** 列表项 `total_amount = 9900`,展示零售价 + +#### Scenario: 赠送套餐在 C 端展示零售价 + +- **WHEN** 平台赠送套餐(零售价 9900 分,实付 0 元),客户查询 C 端订单列表 +- **THEN** 列表项 `total_amount = 9900`,展示零售价 + +--- + +### Requirement: D3 套餐订单详情接口价格展示 + +系统 SHALL 在 C 端订单详情接口中,`total_amount` 字段 MUST 返回零售价,套餐明细中每项的 `price` 字段 MUST 返回零售单价。 + +`ClientOrderPackageItem.price` 来源于 `OrderItem.UnitPrice`,由于 `UnitPrice` 语义已修正为零售单价,详情接口无需修改代码,直接受益于数据层修正。 + +#### Scenario: 后台代购订单详情在 C 端展示零售价 + +- **WHEN** 平台为客户代购套餐(成本价 7000 分,零售价 9900 分),客户查询 C 端订单详情 +- **THEN** `total_amount = 9900`,套餐明细 `price = 9900` + +#### Scenario: 赠送套餐详情在 C 端展示零售价 + +- **WHEN** 平台赠送套餐(零售价 9900 分,实付 0 元),客户查询 C 端订单详情 +- **THEN** `total_amount = 9900`,套餐明细 `price = 9900` diff --git a/openspec/changes/fix-order-price-semantics/specs/package-usage-paid-amount-snapshot/spec.md b/openspec/changes/fix-order-price-semantics/specs/package-usage-paid-amount-snapshot/spec.md new file mode 100644 index 0000000..2c3b3aa --- /dev/null +++ b/openspec/changes/fix-order-price-semantics/specs/package-usage-paid-amount-snapshot/spec.md @@ -0,0 +1,81 @@ +## MODIFIED Requirements + +### Requirement: 套餐使用记录新增零售价快照字段 + +系统 SHALL 在 `tb_package_usage` 中新增 `retail_amount BIGINT NULLABLE` 字段,在创建 `PackageUsage` 记录时快照套餐零售价。 + +**字段规范**: +- 数据库字段:`retail_amount BIGINT NULLABLE` +- Go 字段:`RetailAmount *int64`(单位:分) +- 赋值来源:`order.TotalAmount`(零售价),直接赋值,不转换 +- 为 null 的情况:无订单关联的企业分配套餐(`order_id = 0`) + +**现有 `PaidAmount` 字段保持不变**:继续快照 `order.ActualPaidAmount`(成本价)。 + +**覆盖范围(所有 PackageUsage 写入点)**: +- `internal/service/order/service.go` `activateMainPackage()` +- `internal/service/order/service.go` `activateAddonPackage()` +- `internal/task/auto_purchase.go` `activateMainPackage()` +- `internal/task/auto_purchase.go` `activateAddonPackage()` + +**存量数据回填**:通过迁移 SQL 从关联订单回填,已知历史订单 `total_amount` 可能为成本价(历史数据问题),回填值仅作参考,接受不准确。 + +#### Scenario: 代理钱包支付套餐激活时快照零售价和成本价 + +- **WHEN** 代理以钱包支付购买套餐,零售价 9900 分,成本价 7000 分 +- **THEN** 激活生成的 `PackageUsage.retail_amount = 9900`,`PackageUsage.paid_amount = 7000` + +#### Scenario: 平台代购套餐激活时快照零售价和成本价 + +- **WHEN** 平台代购套餐,零售价 9900 分,成本价 7000 分 +- **THEN** 激活生成的 `PackageUsage.retail_amount = 9900`,`PackageUsage.paid_amount = 7000` + +#### Scenario: 赠送套餐激活时 paid_amount 为 0,retail_amount 为零售价 + +- **WHEN** 平台赠送套餐,零售价 9900 分,实付 0 元 +- **THEN** `PackageUsage.retail_amount = 9900`,`PackageUsage.paid_amount = 0` + +#### Scenario: 无订单关联的套餐 retail_amount 为 null + +- **WHEN** 企业分配套餐(无订单),`order_id = 0` +- **THEN** `PackageUsage.retail_amount = nil`,接口响应中 `retail_amount` 字段缺省(omitempty) + +--- + +### Requirement: 资产套餐接口按调用方角色过滤成本价 + +系统 SHALL 在 `/api/admin/assets/{identifier}/packages` 和 `/api/admin/assets/{identifier}/current-package` 接口中,根据调用方账号类型决定是否返回 `paid_amount`(成本价)字段。 + +**过滤规则**: +- 调用方账号类型为 `platform`:返回 `paid_amount` 和 `retail_amount` 两个字段 +- 调用方账号类型为其他(`agent`、`enterprise`、`personal_customer`):`paid_amount` 不返回(null/omitempty),仅返回 `retail_amount` + +**实现方式**:`GetPackages` / `GetCurrentPackage` 新增 `callerAccountType string` 参数,Service 层根据此参数决定是否填充 `PaidAmount`;Handler 层从 middleware 读取账号类型后传入。 + +**`AssetPackageResponse` DTO 新增字段**: +- `retail_amount *int64`:零售价(分),来源 `PackageUsage.RetailAmount`,omitempty + +#### Scenario: 平台账号查询资产套餐返回两种价格 + +- **WHEN** 平台账号调用 `/api/admin/assets/{identifier}/packages`,套餐零售价 9900 分,成本价 7000 分 +- **THEN** 响应中 `retail_amount = 9900`,`paid_amount = 7000`,两个字段均返回 + +#### Scenario: 代理账号查询资产套餐只返回零售价 + +- **WHEN** 代理账号调用 `/api/admin/assets/{identifier}/packages`,套餐零售价 9900 分,成本价 7000 分 +- **THEN** 响应中 `retail_amount = 9900`,`paid_amount` 字段不返回(null) + +#### Scenario: 平台账号查询当前生效套餐返回两种价格 + +- **WHEN** 平台账号调用 `/api/admin/assets/{identifier}/current-package` +- **THEN** 响应中 `retail_amount` 和 `paid_amount` 均有值 + +#### Scenario: 代理账号查询当前生效套餐只返回零售价 + +- **WHEN** 代理账号调用 `/api/admin/assets/{identifier}/current-package` +- **THEN** 响应中 `retail_amount` 有值,`paid_amount` 不返回 + +#### Scenario: 套餐无零售价快照时 retail_amount 为 null + +- **WHEN** 历史套餐使用记录 `retail_amount` 为 null(存量数据) +- **THEN** 响应中 `retail_amount` 字段缺省(omitempty),不返回 diff --git a/openspec/changes/fix-order-price-semantics/tasks.md b/openspec/changes/fix-order-price-semantics/tasks.md new file mode 100644 index 0000000..8820677 --- /dev/null +++ b/openspec/changes/fix-order-price-semantics/tasks.md @@ -0,0 +1,42 @@ +## 1. 数据库迁移 + +- [x] 1.1 创建迁移文件,在 `tb_order_item` 新增 `cost_price BIGINT NOT NULL DEFAULT 0` 字段 +- [x] 1.2 创建迁移文件,在 `tb_package_usage` 新增 `retail_amount BIGINT NULLABLE` 字段 +- [x] 1.3 在迁移文件中添加存量数据回填 SQL:`tb_package_usage.retail_amount` 从关联订单 `total_amount` 回填(`order_id != 0` 的记录) +- [x] 1.4 执行迁移,验证两个字段已成功添加到数据库 + +## 2. 数据模型更新 + +- [x] 2.1 在 `internal/model/order.go` 的 `OrderItem` 结构体中新增 `CostPrice int64` 字段(GORM tag + 中文注释) +- [x] 2.2 在 `internal/model/package.go` 的 `PackageUsage` 结构体中新增 `RetailAmount *int64` 字段(GORM tag + 中文注释) +- [x] 2.3 在 `internal/model/dto/asset_dto.go` 的 `AssetPackageResponse` 中新增 `RetailAmount *int64` 字段(json tag `retail_amount,omitempty` + description) + +## 3. 修正 Admin 下单价格语义 + +- [x] 3.1 在 `internal/service/order/service.go` 中,修正各场景的 `totalAmount` 赋值:始终使用 `retailTotalAmount`,不再被 `buyerTotalCost` 覆盖(覆盖平台代购、代理自购、代理代购、平台代扣四个子场景) +- [x] 3.2 在 `internal/service/order/service.go` 中,修正 `buildOrderItems`:`UnitPrice` 改从零售价 map 取值,`CostPrice` 从成本价 map 取值(需同时传入两个 map) +- [x] 3.3 在 `internal/service/order/service.go` 中,修正 `createOrderWithWalletPayment`:钱包扣款金额改用 `order.ActualPaidAmount`,不再使用 `order.TotalAmount` +- [ ] 3.4 验证:通过 PostgreSQL MCP 查询新建的后台代购订单,确认 `total_amount` = 零售价,`actual_paid_amount` = 成本价,`order_item.unit_price` = 零售单价,`order_item.cost_price` = 成本单价 + +## 4. 修正套餐激活时的价格快照 + +- [x] 4.1 在 `internal/service/order/service.go` 的 `activateMainPackage()` 中,写入 `PackageUsage.RetailAmount = order.TotalAmount` +- [x] 4.2 在 `internal/service/order/service.go` 的 `activateAddonPackage()` 中,写入 `PackageUsage.RetailAmount = order.TotalAmount` +- [x] 4.3 在 `internal/task/auto_purchase.go` 的 `activateMainPackage()` 中,写入 `PackageUsage.RetailAmount = order.TotalAmount` +- [x] 4.4 在 `internal/task/auto_purchase.go` 的 `activateAddonPackage()` 中,写入 `PackageUsage.RetailAmount = order.TotalAmount` +- [ ] 4.5 验证:通过 PostgreSQL MCP 查询新激活的套餐使用记录,确认 `retail_amount` = 零售价,`paid_amount` = 成本价 + +## 5. 资产套餐接口按角色过滤成本价 + +- [x] 5.1 修改 `internal/service/asset/service.go` 的 `GetPackages()` 签名,新增 `callerAccountType string` 参数;当 `callerAccountType != "platform"` 时,`AssetPackageResponse.PaidAmount` 置为 nil +- [x] 5.2 修改 `internal/service/asset/service.go` 的 `GetCurrentPackage()` 签名,新增 `callerAccountType string` 参数;同上过滤逻辑 +- [x] 5.3 在两个方法中,将 `PackageUsage.RetailAmount` 赋值给 `AssetPackageResponse.RetailAmount` +- [x] 5.4 修改 `internal/handler/admin/asset.go` 的 `Packages()` 方法:从 middleware 读取调用方账号类型,传入 `GetPackages()` +- [x] 5.5 修改 `internal/handler/admin/asset.go` 的 `CurrentPackage()` 方法:从 middleware 读取调用方账号类型,传入 `GetCurrentPackage()` +- [ ] 5.6 验证:用平台账号调用 `/api/admin/assets/{identifier}/packages`,确认响应包含 `paid_amount` 和 `retail_amount`;用代理账号调用,确认响应只有 `retail_amount`,无 `paid_amount` + +## 6. 验证 C 端订单价格展示 + +- [x] 6.1 通过 PostgreSQL MCP 查询一条后台代购订单,确认 `total_amount` 已为零售价(历史数据为成本价,已知;新建订单需运行时验证) +- [ ] 6.2 调用 `GET /api/c/v1/orders?identifier=xxx`,确认列表中 `total_amount` 展示零售价 +- [ ] 6.3 调用 `GET /api/c/v1/orders/:id`,确认详情中 `total_amount` 和套餐 `price` 均为零售价