feat: 实现客户端换货系统(client-exchange-system)

新增完整换货生命周期管理:后台发起 → 客户端填收货信息 → 后台发货 → 确认完成(含可选全量迁移) → 旧资产转新再销售

后台接口(7个):
- POST /api/admin/exchanges(发起换货)
- GET /api/admin/exchanges(换货列表)
- GET /api/admin/exchanges/:id(换货详情)
- POST /api/admin/exchanges/:id/ship(发货)
- POST /api/admin/exchanges/:id/complete(确认完成+可选迁移)
- POST /api/admin/exchanges/:id/cancel(取消)
- POST /api/admin/exchanges/:id/renew(旧资产转新)

客户端接口(2个):
- GET /api/c/v1/exchange/pending(查询换货通知)
- POST /api/c/v1/exchange/:id/shipping-info(填写收货信息)

核心能力:
- ExchangeOrder 模型与状态机(1待填写→2待发货→3已发货→4已完成,1/2可取消→5)
- 全量迁移事务(11张表:钱包、套餐、标签、客户绑定等)
- 旧资产转新(generation+1、状态重置、新钱包、历史隔离)
- 旧 CardReplacementRecord 表改名为 legacy,is_replaced 过滤改为查新表
- 数据库迁移:000085 新建 tb_exchange_order,000086 旧表改名
This commit is contained in:
2026-03-19 13:26:54 +08:00
parent df76e33105
commit e78f5794b9
41 changed files with 5242 additions and 10 deletions

View File

@@ -5,11 +5,41 @@ import (
"github.com/break/junhong_cmp_fiber/internal/handler/app"
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/go-playground/validator/v10"
)
func initHandlers(svc *services, deps *Dependencies) *Handlers {
validate := validator.New()
personalCustomerDeviceStore := postgres.NewPersonalCustomerDeviceStore(deps.DB)
assetWalletStore := postgres.NewAssetWalletStore(deps.DB, deps.Redis)
packageStore := postgres.NewPackageStore(deps.DB)
shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(deps.DB)
iotCardStore := postgres.NewIotCardStore(deps.DB, deps.Redis)
deviceStore := postgres.NewDeviceStore(deps.DB, deps.Redis)
assetWalletTransactionStore := postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis)
assetRechargeStore := postgres.NewAssetRechargeStore(deps.DB, deps.Redis)
personalCustomerOpenIDStore := postgres.NewPersonalCustomerOpenIDStore(deps.DB)
orderStore := postgres.NewOrderStore(deps.DB, deps.Redis)
packageSeriesStore := postgres.NewPackageSeriesStore(deps.DB)
shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(deps.DB)
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis)
carrierStore := postgres.NewCarrierStore(deps.DB)
clientOrderService := clientOrderSvc.New(
svc.Asset,
svc.PurchaseValidation,
orderStore,
assetRechargeStore,
assetWalletStore,
personalCustomerDeviceStore,
personalCustomerOpenIDStore,
svc.WechatConfig,
packageSeriesStore,
shopSeriesAllocationStore,
deps.Redis,
deps.Logger,
)
return &Handlers{
Auth: authHandler.NewHandler(svc.Auth, validate),
@@ -18,6 +48,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Permission: admin.NewPermissionHandler(svc.Permission),
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
ClientAsset: app.NewClientAssetHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger),
ClientWallet: app.NewClientWalletHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, assetWalletTransactionStore, assetRechargeStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
ClientOrder: app.NewClientOrderHandler(clientOrderService, svc.Asset, orderStore, personalCustomerDeviceStore, iotCardStore, deviceStore, deps.Logger, deps.DB),
ClientExchange: app.NewClientExchangeHandler(svc.Exchange),
ClientRealname: app.NewClientRealnameHandler(svc.Asset, personalCustomerDeviceStore, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger),
ClientDevice: app.NewClientDeviceHandler(svc.Asset, personalCustomerDeviceStore, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
Shop: admin.NewShopHandler(svc.Shop),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
@@ -43,6 +79,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, validate),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, svc.AgentRecharge, deps.WechatPayment),
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),

View File

@@ -20,6 +20,7 @@ import (
enterpriseSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise"
enterpriseCardSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card"
enterpriseDeviceSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_device"
exchangeSvc "github.com/break/junhong_cmp_fiber/internal/service/exchange"
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import"
myCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/my_commission"
@@ -76,6 +77,7 @@ type services struct {
CommissionStats *commissionStatsSvc.Service
PurchaseValidation *purchaseValidationSvc.Service
Order *orderSvc.Service
Exchange *exchangeSvc.Service
Recharge *rechargeSvc.Service
PollingConfig *pollingSvc.ConfigService
PollingConcurrency *pollingSvc.ConcurrencyService
@@ -167,6 +169,7 @@ func initServices(s *stores, deps *Dependencies) *services {
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, deps.QueueClient, deps.Logger),
Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, s.PersonalCustomerDevice, deps.Logger),
Recharge: rechargeSvc.New(deps.DB, s.AssetRecharge, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, deps.Logger),
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig),
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),

View File

@@ -40,6 +40,8 @@ type stores struct {
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
Order *postgres.OrderStore
OrderItem *postgres.OrderItemStore
ExchangeOrder *postgres.ExchangeOrderStore
ResourceTag *postgres.ResourceTagStore
PollingConfig *postgres.PollingConfigStore
PollingConcurrencyConfig *postgres.PollingConcurrencyConfigStore
PollingAlertRule *postgres.PollingAlertRuleStore
@@ -96,6 +98,8 @@ func initStores(deps *Dependencies) *stores {
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
ExchangeOrder: postgres.NewExchangeOrderStore(deps.DB),
ResourceTag: postgres.NewResourceTagStore(deps.DB),
PollingConfig: postgres.NewPollingConfigStore(deps.DB),
PollingConcurrencyConfig: postgres.NewPollingConcurrencyConfigStore(deps.DB),
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),

View File

@@ -16,6 +16,12 @@ type Handlers struct {
Permission *admin.PermissionHandler
PersonalCustomer *app.PersonalCustomerHandler
ClientAuth *app.ClientAuthHandler
ClientAsset *app.ClientAssetHandler
ClientWallet *app.ClientWalletHandler
ClientOrder *app.ClientOrderHandler
ClientExchange *app.ClientExchangeHandler
ClientRealname *app.ClientRealnameHandler
ClientDevice *app.ClientDeviceHandler
Shop *admin.ShopHandler
ShopRole *admin.ShopRoleHandler
AdminAuth *admin.AuthHandler
@@ -41,6 +47,7 @@ type Handlers struct {
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
ShopSeriesGrant *admin.ShopSeriesGrantHandler
AdminOrder *admin.OrderHandler
AdminExchange *admin.ExchangeHandler
PaymentCallback *callback.PaymentHandler
PollingConfig *admin.PollingConfigHandler
PollingConcurrency *admin.PollingConcurrencyHandler

View File

@@ -0,0 +1,131 @@
package admin
import (
"strconv"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
)
type ExchangeHandler struct {
service *exchangeService.Service
validator *validator.Validate
}
func NewExchangeHandler(service *exchangeService.Service, validator *validator.Validate) *ExchangeHandler {
return &ExchangeHandler{service: service, validator: validator}
}
func (h *ExchangeHandler) Create(c *fiber.Ctx) error {
var req dto.CreateExchangeRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
data, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, data)
}
func (h *ExchangeHandler) List(c *fiber.Ctx) error {
var req dto.ExchangeListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
data, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, data)
}
func (h *ExchangeHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
}
data, err := h.service.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, data)
}
func (h *ExchangeHandler) Ship(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
}
var req dto.ExchangeShipRequest
if err = c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err = h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
data, err := h.service.Ship(c.UserContext(), uint(id), &req)
if err != nil {
return err
}
return response.Success(c, data)
}
func (h *ExchangeHandler) Complete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
}
if err = h.service.Complete(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}
func (h *ExchangeHandler) Cancel(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
}
var req dto.ExchangeCancelRequest
if err = c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err = h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err = h.service.Cancel(c.UserContext(), uint(id), &req); err != nil {
return err
}
return response.Success(c, nil)
}
func (h *ExchangeHandler) Renew(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
}
if err = h.service.Renew(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -0,0 +1,57 @@
package app
import (
"strconv"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
)
type ClientExchangeHandler struct {
service *exchangeService.Service
validator *validator.Validate
}
func NewClientExchangeHandler(service *exchangeService.Service) *ClientExchangeHandler {
return &ClientExchangeHandler{service: service, validator: validator.New()}
}
func (h *ClientExchangeHandler) GetPending(c *fiber.Ctx) error {
var req dto.ClientExchangePendingRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
data, err := h.service.GetPending(c.UserContext(), req.Identifier)
if err != nil {
return err
}
return response.Success(c, data)
}
func (h *ClientExchangeHandler) SubmitShippingInfo(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam)
}
var req dto.ClientShippingInfoRequest
if err = c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err = h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err = h.service.SubmitShippingInfo(c.UserContext(), uint(id), &req); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -92,6 +92,7 @@ type AssetRechargeRecord struct {
LinkedOrderType string `gorm:"column:linked_order_type;type:varchar(20);comment:关联订单类型" json:"linked_order_type,omitempty"`
LinkedCarrierType string `gorm:"column:linked_carrier_type;type:varchar(20);comment:关联载体类型" json:"linked_carrier_type,omitempty"`
LinkedCarrierID *uint `gorm:"column:linked_carrier_id;type:bigint;comment:关联载体ID" json:"linked_carrier_id,omitempty"`
AutoPurchaseStatus string `gorm:"column:auto_purchase_status;type:varchar(20);default:'';comment:强充自动代购状态(pending-待处理 success-成功 failed-失败)" json:"auto_purchase_status,omitempty"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
}

View File

@@ -0,0 +1,104 @@
package dto
import "time"
type CreateExchangeRequest struct {
OldAssetType string `json:"old_asset_type" validate:"required,oneof=iot_card device" required:"true" description:"旧资产类型 (iot_card:物联网卡, device:设备)"`
OldIdentifier string `json:"old_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"旧资产标识符(ICCID/虚拟号/IMEI/SN)"`
ExchangeReason string `json:"exchange_reason" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"换货原因"`
Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"`
}
type ExchangeListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"`
Identifier string `json:"identifier" query:"identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识符搜索(旧资产/新资产标识符模糊匹配)"`
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"`
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
}
type ExchangeShipRequest struct {
ExpressCompany string `json:"express_company" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递公司"`
ExpressNo string `json:"express_no" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递单号"`
NewIdentifier string `json:"new_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"新资产标识符(ICCID/虚拟号/IMEI/SN)"`
MigrateData bool `json:"migrate_data" required:"true" description:"是否执行全量迁移 (true:执行, false:不执行)"`
}
type ExchangeCancelRequest struct {
Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"取消备注"`
}
type ClientShippingInfoRequest struct {
RecipientName string `json:"recipient_name" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"收件人姓名"`
RecipientPhone string `json:"recipient_phone" validate:"required,min=1,max=20" required:"true" minLength:"1" maxLength:"20" description:"收件人电话"`
RecipientAddress string `json:"recipient_address" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"收货地址"`
}
type ClientExchangePendingRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(ICCID/虚拟号/IMEI/SN)"`
}
type ExchangeIDRequest struct {
ID uint `path:"id" required:"true" description:"换货单ID"`
}
type ExchangeShipParams struct {
ID uint `path:"id" required:"true" description:"换货单ID"`
ExchangeShipRequest
}
type ExchangeCancelParams struct {
ID uint `path:"id" required:"true" description:"换货单ID"`
ExchangeCancelRequest
}
type ClientShippingInfoParams struct {
ID uint `path:"id" required:"true" description:"换货单ID"`
ClientShippingInfoRequest
}
type ExchangeOrderResponse struct {
ID uint `json:"id" description:"换货单ID"`
ExchangeNo string `json:"exchange_no" description:"换货单号"`
OldAssetType string `json:"old_asset_type" description:"旧资产类型 (iot_card:物联网卡, device:设备)"`
OldAssetID uint `json:"old_asset_id" description:"旧资产ID"`
OldAssetIdentifier string `json:"old_asset_identifier" description:"旧资产标识符"`
NewAssetType string `json:"new_asset_type" description:"新资产类型 (iot_card:物联网卡, device:设备)"`
NewAssetID *uint `json:"new_asset_id,omitempty" description:"新资产ID"`
NewAssetIdentifier string `json:"new_asset_identifier" description:"新资产标识符"`
RecipientName string `json:"recipient_name" description:"收件人姓名"`
RecipientPhone string `json:"recipient_phone" description:"收件人电话"`
RecipientAddress string `json:"recipient_address" description:"收货地址"`
ExpressCompany string `json:"express_company" description:"快递公司"`
ExpressNo string `json:"express_no" description:"快递单号"`
MigrateData bool `json:"migrate_data" description:"是否执行全量迁移"`
MigrationCompleted bool `json:"migration_completed" description:"迁移是否已完成"`
MigrationBalance int64 `json:"migration_balance" description:"迁移转移金额(分)"`
ExchangeReason string `json:"exchange_reason" description:"换货原因"`
Remark *string `json:"remark,omitempty" description:"备注"`
Status int `json:"status" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"`
StatusText string `json:"status_text" description:"换货状态文本"`
ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
DeletedAt *time.Time `json:"deleted_at,omitempty" description:"删除时间"`
Creator uint `json:"creator" description:"创建人ID"`
Updater uint `json:"updater" description:"更新人ID"`
}
type ExchangeListResponse struct {
List []*ExchangeOrderResponse `json:"list" description:"换货单列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页数量"`
}
type ClientExchangePendingResponse struct {
ID uint `json:"id" description:"换货单ID"`
ExchangeNo string `json:"exchange_no" description:"换货单号"`
Status int `json:"status" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"`
StatusText string `json:"status_text" description:"换货状态文本"`
ExchangeReason string `json:"exchange_reason" description:"换货原因"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}

View File

@@ -0,0 +1,65 @@
package model
import (
"fmt"
"math/rand"
"time"
"gorm.io/gorm"
)
// ExchangeOrder 换货单模型
// 承载客户端换货的完整生命周期:后台发起 → 客户端填写收货信息 → 后台发货 → 确认完成(含可选全量迁移) → 旧资产可转新
// 状态机1-待填写信息 → 2-待发货 → 3-已发货待确认 → 4-已完成1/2 时可取消 → 5-已取消
type ExchangeOrder struct {
gorm.Model
BaseModel `gorm:"embedded"`
// 单号
ExchangeNo string `gorm:"column:exchange_no;type:varchar(50);not null;uniqueIndex:idx_exchange_order_no,where:deleted_at IS NULL;comment:换货单号(EXC+日期+随机数)" json:"exchange_no"`
// 旧资产快照
OldAssetType string `gorm:"column:old_asset_type;type:varchar(20);not null;comment:旧资产类型(iot_card/device)" json:"old_asset_type"`
OldAssetID uint `gorm:"column:old_asset_id;not null;index:idx_exchange_order_old_asset;comment:旧资产ID" json:"old_asset_id"`
OldAssetIdentifier string `gorm:"column:old_asset_identifier;type:varchar(100);not null;comment:旧资产标识符(ICCID/虚拟号)" json:"old_asset_identifier"`
// 新资产快照(发货时填写)
NewAssetType string `gorm:"column:new_asset_type;type:varchar(20);comment:新资产类型(iot_card/device)" json:"new_asset_type"`
NewAssetID *uint `gorm:"column:new_asset_id;comment:新资产ID" json:"new_asset_id,omitempty"`
NewAssetIdentifier string `gorm:"column:new_asset_identifier;type:varchar(100);comment:新资产标识符(ICCID/虚拟号)" json:"new_asset_identifier"`
// 收货信息(客户端填写)
RecipientName string `gorm:"column:recipient_name;type:varchar(50);comment:收件人姓名" json:"recipient_name"`
RecipientPhone string `gorm:"column:recipient_phone;type:varchar(20);comment:收件人电话" json:"recipient_phone"`
RecipientAddress string `gorm:"column:recipient_address;type:text;comment:收货地址" json:"recipient_address"`
// 物流信息(后台发货时填写)
ExpressCompany string `gorm:"column:express_company;type:varchar(100);comment:快递公司" json:"express_company"`
ExpressNo string `gorm:"column:express_no;type:varchar(100);comment:快递单号" json:"express_no"`
// 迁移相关
MigrateData bool `gorm:"column:migrate_data;type:boolean;default:false;comment:是否执行全量迁移" json:"migrate_data"`
MigrationCompleted bool `gorm:"column:migration_completed;type:boolean;default:false;comment:迁移是否已完成" json:"migration_completed"`
MigrationBalance int64 `gorm:"column:migration_balance;type:bigint;default:0;comment:迁移转移金额(分)" json:"migration_balance"`
// 业务信息
ExchangeReason string `gorm:"column:exchange_reason;type:varchar(100);not null;comment:换货原因" json:"exchange_reason"`
Remark *string `gorm:"column:remark;type:text;comment:备注" json:"remark,omitempty"`
Status int `gorm:"column:status;type:int;not null;default:1;index:idx_exchange_order_status;comment:换货状态 1-待填写信息 2-待发货 3-已发货待确认 4-已完成 5-已取消" json:"status"`
// 多租户
ShopID *uint `gorm:"column:shop_id;index;comment:所属店铺ID" json:"shop_id,omitempty"`
}
// TableName 指定表名
func (ExchangeOrder) TableName() string {
return "tb_exchange_order"
}
// GenerateExchangeNo 生成换货单号
// 格式EXC + 年月日时分秒 + 6位随机数如 EXC20260319143052123456
func GenerateExchangeNo() string {
now := time.Now()
randomNum := rand.Intn(1000000)
return fmt.Sprintf("EXC%s%06d", now.Format("20060102150405"), randomNum)
}

View File

@@ -92,6 +92,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.AdminOrder != nil {
registerAdminOrderRoutes(authGroup, handlers.AdminOrder, doc, basePath)
}
if handlers.AdminExchange != nil {
registerAdminExchangeRoutes(authGroup, handlers.AdminExchange, doc, basePath)
}
if handlers.PollingConfig != nil {
registerPollingConfigRoutes(authGroup, handlers.PollingConfig, doc, basePath)
}

View File

@@ -0,0 +1,66 @@
package routes
import (
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
"github.com/gofiber/fiber/v2"
)
func registerAdminExchangeRoutes(router fiber.Router, handler *admin.ExchangeHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/exchanges", handler.Create, RouteSpec{
Summary: "创建换货单",
Tags: []string{"换货管理"},
Input: new(dto.CreateExchangeRequest),
Output: new(dto.ExchangeOrderResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/exchanges", handler.List, RouteSpec{
Summary: "获取换货单列表",
Tags: []string{"换货管理"},
Input: new(dto.ExchangeListRequest),
Output: new(dto.ExchangeListResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/exchanges/:id", handler.Get, RouteSpec{
Summary: "获取换货单详情",
Tags: []string{"换货管理"},
Input: new(dto.ExchangeIDRequest),
Output: new(dto.ExchangeOrderResponse),
Auth: true,
})
Register(router, doc, basePath, "POST", "/exchanges/:id/ship", handler.Ship, RouteSpec{
Summary: "换货发货",
Tags: []string{"换货管理"},
Input: new(dto.ExchangeShipParams),
Output: new(dto.ExchangeOrderResponse),
Auth: true,
})
Register(router, doc, basePath, "POST", "/exchanges/:id/complete", handler.Complete, RouteSpec{
Summary: "确认换货完成",
Tags: []string{"换货管理"},
Input: new(dto.ExchangeIDRequest),
Output: nil,
Auth: true,
})
Register(router, doc, basePath, "POST", "/exchanges/:id/cancel", handler.Cancel, RouteSpec{
Summary: "取消换货",
Tags: []string{"换货管理"},
Input: new(dto.ExchangeCancelParams),
Output: nil,
Auth: true,
})
Register(router, doc, basePath, "POST", "/exchanges/:id/renew", handler.Renew, RouteSpec{
Summary: "旧资产转新",
Tags: []string{"换货管理"},
Input: new(dto.ExchangeIDRequest),
Output: nil,
Auth: true,
})
}

View File

@@ -97,4 +97,164 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
Input: &apphandler.UpdateProfileRequest{},
Output: nil,
})
Register(authGroup, doc, basePath, "GET", "/asset/info", handlers.ClientAsset.GetAssetInfo, RouteSpec{
Summary: "资产信息",
Tags: []string{"个人客户 - 资产"},
Auth: true,
Input: &dto.AssetInfoRequest{},
Output: &dto.AssetInfoResponse{},
})
Register(authGroup, doc, basePath, "GET", "/asset/packages", handlers.ClientAsset.GetAvailablePackages, RouteSpec{
Summary: "资产可购套餐列表",
Tags: []string{"个人客户 - 资产"},
Auth: true,
Input: &dto.AssetPackageListRequest{},
Output: &dto.AssetPackageListResponse{},
})
Register(authGroup, doc, basePath, "GET", "/asset/package-history", handlers.ClientAsset.GetPackageHistory, RouteSpec{
Summary: "资产套餐历史",
Tags: []string{"个人客户 - 资产"},
Auth: true,
Input: &dto.AssetPackageHistoryRequest{},
Output: &dto.AssetPackageHistoryResponse{},
})
Register(authGroup, doc, basePath, "POST", "/asset/refresh", handlers.ClientAsset.RefreshAsset, RouteSpec{
Summary: "资产刷新",
Tags: []string{"个人客户 - 资产"},
Auth: true,
Input: &dto.AssetRefreshRequest{},
Output: &dto.AssetRefreshResponse{},
})
Register(authGroup, doc, basePath, "GET", "/wallet/detail", handlers.ClientWallet.GetWalletDetail, RouteSpec{
Summary: "钱包详情",
Tags: []string{"个人客户 - 钱包"},
Auth: true,
Input: &dto.WalletDetailRequest{},
Output: &dto.WalletDetailResponse{},
})
Register(authGroup, doc, basePath, "GET", "/wallet/transactions", handlers.ClientWallet.GetWalletTransactions, RouteSpec{
Summary: "钱包流水列表",
Tags: []string{"个人客户 - 钱包"},
Auth: true,
Input: &dto.WalletTransactionListRequest{},
Output: &dto.WalletTransactionListResponse{},
})
Register(authGroup, doc, basePath, "GET", "/wallet/recharge-check", handlers.ClientWallet.GetRechargeCheck, RouteSpec{
Summary: "充值前校验",
Tags: []string{"个人客户 - 钱包"},
Auth: true,
Input: &dto.ClientRechargeCheckRequest{},
Output: &dto.ClientRechargeCheckResponse{},
})
Register(authGroup, doc, basePath, "POST", "/wallet/recharge", handlers.ClientWallet.CreateRecharge, RouteSpec{
Summary: "创建充值订单",
Tags: []string{"个人客户 - 钱包"},
Auth: true,
Input: &dto.ClientCreateRechargeRequest{},
Output: &dto.ClientRechargeResponse{},
})
Register(authGroup, doc, basePath, "GET", "/wallet/recharges", handlers.ClientWallet.GetRechargeList, RouteSpec{
Summary: "充值记录列表",
Tags: []string{"个人客户 - 钱包"},
Auth: true,
Input: &dto.ClientRechargeListRequest{},
Output: &dto.ClientRechargeListResponse{},
})
Register(authGroup, doc, basePath, "POST", "/orders/create", handlers.ClientOrder.CreateOrder, RouteSpec{
Summary: "创建订单",
Tags: []string{"个人客户 - 订单"},
Auth: true,
Input: &dto.ClientCreateOrderRequest{},
Output: &dto.ClientCreateOrderResponse{},
})
Register(authGroup, doc, basePath, "GET", "/orders", handlers.ClientOrder.ListOrders, RouteSpec{
Summary: "订单列表",
Tags: []string{"个人客户 - 订单"},
Auth: true,
Input: &dto.ClientOrderListRequest{},
Output: &dto.ClientOrderListResponse{},
})
Register(authGroup, doc, basePath, "GET", "/orders/:id", handlers.ClientOrder.GetOrderDetail, RouteSpec{
Summary: "订单详情",
Tags: []string{"个人客户 - 订单"},
Auth: true,
Input: &dto.IDReq{},
Output: &dto.ClientOrderDetailResponse{},
})
Register(authGroup, doc, basePath, "GET", "/exchange/pending", handlers.ClientExchange.GetPending, RouteSpec{
Summary: "查询待处理换货单",
Tags: []string{"个人客户 - 换货"},
Auth: true,
Input: &dto.ClientExchangePendingRequest{},
Output: &dto.ClientExchangePendingResponse{},
})
Register(authGroup, doc, basePath, "POST", "/exchange/:id/shipping-info", handlers.ClientExchange.SubmitShippingInfo, RouteSpec{
Summary: "提交收货信息",
Tags: []string{"个人客户 - 换货"},
Auth: true,
Input: &dto.ClientShippingInfoParams{},
Output: nil,
})
Register(authGroup, doc, basePath, "GET", "/realname/link", handlers.ClientRealname.GetRealnameLink, RouteSpec{
Summary: "获取实名认证链接",
Tags: []string{"个人客户 - 实名"},
Auth: true,
Input: &dto.RealnimeLinkRequest{},
Output: &dto.RealnimeLinkResponse{},
})
Register(authGroup, doc, basePath, "GET", "/device/cards", handlers.ClientDevice.GetDeviceCards, RouteSpec{
Summary: "获取设备卡列表",
Tags: []string{"个人客户 - 设备"},
Auth: true,
Input: &dto.DeviceCardListRequest{},
Output: &dto.DeviceCardListResponse{},
})
Register(authGroup, doc, basePath, "POST", "/device/reboot", handlers.ClientDevice.RebootDevice, RouteSpec{
Summary: "设备重启",
Tags: []string{"个人客户 - 设备"},
Auth: true,
Input: &dto.DeviceRebootRequest{},
Output: &dto.DeviceOperationResponse{},
})
Register(authGroup, doc, basePath, "POST", "/device/factory-reset", handlers.ClientDevice.FactoryResetDevice, RouteSpec{
Summary: "恢复出厂设置",
Tags: []string{"个人客户 - 设备"},
Auth: true,
Input: &dto.DeviceFactoryResetRequest{},
Output: &dto.DeviceOperationResponse{},
})
Register(authGroup, doc, basePath, "POST", "/device/wifi", handlers.ClientDevice.SetWiFi, RouteSpec{
Summary: "设备WiFi配置",
Tags: []string{"个人客户 - 设备"},
Auth: true,
Input: &dto.DeviceWifiRequest{},
Output: &dto.DeviceOperationResponse{},
})
Register(authGroup, doc, basePath, "POST", "/device/switch-card", handlers.ClientDevice.SwitchCard, RouteSpec{
Summary: "设备切卡",
Tags: []string{"个人客户 - 设备"},
Auth: true,
Input: &dto.DeviceSwitchCardRequest{},
Output: &dto.DeviceSwitchCardResponse{},
})
}

View File

@@ -0,0 +1,243 @@
package exchange
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *Service) executeMigration(ctx context.Context, order *model.ExchangeOrder) (int64, error) {
var migrationBalance int64
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if order.NewAssetID == nil || *order.NewAssetID == 0 {
return errors.New(errors.CodeInvalidParam, "新资产信息缺失")
}
oldAsset, err := s.resolveAssetByIdentifier(ctx, order.OldAssetType, order.OldAssetIdentifier)
if err != nil {
return err
}
newAsset, err := s.resolveAssetByIdentifier(ctx, order.OldAssetType, order.NewAssetIdentifier)
if err != nil {
return err
}
migrationBalance, err = s.transferWalletBalanceWithTx(ctx, tx, order, oldAsset, newAsset)
if err != nil {
return err
}
if err = s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return err
}
if err = s.copyAccumulatedFieldsWithTx(tx, oldAsset, newAsset); err != nil {
return err
}
if err = s.copyResourceTagsWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return err
}
if oldAsset.VirtualNo != "" && newAsset.VirtualNo != "" {
if err = tx.Model(&model.PersonalCustomerDevice{}).
Where("virtual_no = ?", oldAsset.VirtualNo).
Updates(map[string]any{"virtual_no": newAsset.VirtualNo, "updated_at": time.Now()}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新客户绑定关系失败")
}
}
if err = s.updateOldAssetStatusWithTx(tx, oldAsset); err != nil {
return err
}
if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", order.ID).Updates(map[string]any{
"migration_completed": true,
"migration_balance": migrationBalance,
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新换货单迁移状态失败")
}
return nil
})
if err != nil {
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "执行全量迁移失败")
}
return migrationBalance, nil
}
func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (int64, error) {
var oldWallet model.AssetWallet
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", oldAsset.AssetType, oldAsset.AssetID).First(&oldWallet).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询旧资产钱包失败")
}
}
var newWallet model.AssetWallet
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", newAsset.AssetType, newAsset.AssetID).First(&newWallet).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询新资产钱包失败")
}
shopTag := uint(0)
if newAsset.ShopID != nil {
shopTag = *newAsset.ShopID
}
newWallet = model.AssetWallet{ResourceType: newAsset.AssetType, ResourceID: newAsset.AssetID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: 1, Version: 0, ShopIDTag: shopTag}
if err = tx.WithContext(ctx).Create(&newWallet).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "创建新资产钱包失败")
}
}
migrationBalance := oldWallet.Balance
if migrationBalance <= 0 {
return 0, nil
}
beforeBalance := newWallet.Balance
if err := tx.WithContext(ctx).Model(&model.AssetWallet{}).Where("id = ?", oldWallet.ID).Updates(map[string]any{"balance": 0, "updated_at": time.Now()}).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "清空旧资产钱包余额失败")
}
if err := tx.WithContext(ctx).Model(&model.AssetWallet{}).Where("id = ?", newWallet.ID).Updates(map[string]any{"balance": gorm.Expr("balance + ?", migrationBalance), "updated_at": time.Now()}).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "增加新资产钱包余额失败")
}
refType := "exchange"
if err := tx.WithContext(ctx).Create(&model.AssetWalletTransaction{
AssetWalletID: newWallet.ID,
ResourceType: newAsset.AssetType,
ResourceID: newAsset.AssetID,
UserID: middleware.GetUserIDFromContext(ctx),
TransactionType: "refund",
Amount: migrationBalance,
BalanceBefore: beforeBalance,
BalanceAfter: beforeBalance + migrationBalance,
Status: 1,
ReferenceType: &refType,
ReferenceNo: &order.ExchangeNo,
Creator: middleware.GetUserIDFromContext(ctx),
ShopIDTag: newWallet.ShopIDTag,
EnterpriseIDTag: newWallet.EnterpriseIDTag,
}).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "写入迁移钱包流水失败")
}
return migrationBalance, nil
}
func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
query := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted})
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
query = query.Where("iot_card_id = ?", oldAsset.AssetID)
} else {
query = query.Where("device_id = ?", oldAsset.AssetID)
}
var usageIDs []uint
if err := query.Pluck("id", &usageIDs).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
}
if len(usageIDs) == 0 {
return nil
}
updates := map[string]any{"updated_at": time.Now()}
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
updates["iot_card_id"] = newAsset.AssetID
} else {
updates["device_id"] = newAsset.AssetID
}
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id IN ?", usageIDs).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败")
}
if err := tx.WithContext(ctx).Model(&model.PackageUsageDailyRecord{}).Where("package_usage_id IN ?", usageIDs).Update("updated_at", gorm.Expr("updated_at")).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败")
}
return nil
}
func (s *Service) copyAccumulatedFieldsWithTx(tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
if oldAsset.Card == nil {
return errors.New(errors.CodeAssetNotFound)
}
if err := tx.Model(&model.IotCard{}).Where("id = ?", newAsset.AssetID).Updates(map[string]any{
"accumulated_recharge": oldAsset.Card.AccumulatedRecharge,
"first_commission_paid": oldAsset.Card.FirstCommissionPaid,
"accumulated_recharge_by_series": oldAsset.Card.AccumulatedRechargeBySeriesJSON,
"first_recharge_triggered_by_series": oldAsset.Card.FirstRechargeTriggeredBySeriesJSON,
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复制旧卡累计字段失败")
}
return nil
}
if oldAsset.Device == nil {
return errors.New(errors.CodeAssetNotFound)
}
if err := tx.Model(&model.Device{}).Where("id = ?", newAsset.AssetID).Updates(map[string]any{
"accumulated_recharge": oldAsset.Device.AccumulatedRecharge,
"first_commission_paid": oldAsset.Device.FirstCommissionPaid,
"accumulated_recharge_by_series": oldAsset.Device.AccumulatedRechargeBySeriesJSON,
"first_recharge_triggered_by_series": oldAsset.Device.FirstRechargeTriggeredBySeriesJSON,
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复制旧设备累计字段失败")
}
return nil
}
func (s *Service) copyResourceTagsWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
var tags []*model.ResourceTag
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", oldAsset.AssetType, oldAsset.AssetID).Find(&tags).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询资源标签失败")
}
var creator = middleware.GetUserIDFromContext(ctx)
for _, item := range tags {
if item == nil {
continue
}
record := &model.ResourceTag{
ResourceType: newAsset.AssetType,
ResourceID: newAsset.AssetID,
TagID: item.TagID,
EnterpriseID: item.EnterpriseID,
ShopID: item.ShopID,
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
}
if err := tx.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "resource_type"}, {Name: "resource_id"}, {Name: "tag_id"}}, DoNothing: true}).Create(record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复制资源标签失败")
}
}
return nil
}
func (s *Service) updateOldAssetStatusWithTx(tx *gorm.DB, oldAsset *resolvedExchangeAsset) error {
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
if err := tx.Model(&model.IotCard{}).Where("id = ?", oldAsset.AssetID).Updates(map[string]any{"asset_status": 3, "updated_at": time.Now()}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新旧卡状态失败")
}
return nil
}
if err := tx.Model(&model.Device{}).Where("id = ?", oldAsset.AssetID).Updates(map[string]any{"asset_status": 3, "updated_at": time.Now()}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新旧设备状态失败")
}
return nil
}

View File

@@ -0,0 +1,487 @@
package exchange
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap"
"gorm.io/gorm"
)
type Service struct {
db *gorm.DB
exchangeStore *postgres.ExchangeOrderStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
assetWalletStore *postgres.AssetWalletStore
assetWalletTransactionStore *postgres.AssetWalletTransactionStore
packageUsageStore *postgres.PackageUsageStore
packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore
resourceTagStore *postgres.ResourceTagStore
personalCustomerDeviceStore *postgres.PersonalCustomerDeviceStore
logger *zap.Logger
}
func New(
db *gorm.DB,
exchangeStore *postgres.ExchangeOrderStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
assetWalletStore *postgres.AssetWalletStore,
assetWalletTransactionStore *postgres.AssetWalletTransactionStore,
packageUsageStore *postgres.PackageUsageStore,
packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore,
resourceTagStore *postgres.ResourceTagStore,
personalCustomerDeviceStore *postgres.PersonalCustomerDeviceStore,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
exchangeStore: exchangeStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
assetWalletStore: assetWalletStore,
assetWalletTransactionStore: assetWalletTransactionStore,
packageUsageStore: packageUsageStore,
packageUsageDailyRecordStore: packageUsageDailyRecordStore,
resourceTagStore: resourceTagStore,
personalCustomerDeviceStore: personalCustomerDeviceStore,
logger: logger,
}
}
func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*dto.ExchangeOrderResponse, error) {
asset, err := s.resolveAssetByIdentifier(ctx, req.OldAssetType, req.OldIdentifier)
if err != nil {
return nil, err
}
if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil {
return nil, errors.New(errors.CodeExchangeInProgress)
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败")
}
shopID := middleware.GetShopIDFromContext(ctx)
creator := middleware.GetUserIDFromContext(ctx)
order := &model.ExchangeOrder{
ExchangeNo: model.GenerateExchangeNo(),
OldAssetType: asset.AssetType,
OldAssetID: asset.AssetID,
OldAssetIdentifier: asset.Identifier,
ExchangeReason: req.ExchangeReason,
Remark: req.Remark,
Status: constants.ExchangeStatusPendingInfo,
MigrationCompleted: false,
MigrationBalance: 0,
MigrateData: false,
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
}
if shopID > 0 {
order.ShopID = &shopID
}
if err = s.exchangeStore.Create(ctx, order); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建换货单失败")
}
return s.toExchangeOrderResponse(order), nil
}
func (s *Service) List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
page := req.Page
page = max(page, 1)
pageSize := req.PageSize
if pageSize < 1 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
filters := make(map[string]any)
if req.Status != nil {
filters["status"] = *req.Status
}
if req.Identifier != "" {
filters["identifier"] = req.Identifier
}
if req.CreatedAtStart != nil {
filters["created_at_start"] = *req.CreatedAtStart
}
if req.CreatedAtEnd != nil {
filters["created_at_end"] = *req.CreatedAtEnd
}
orders, total, err := s.exchangeStore.List(ctx, filters, page, pageSize)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单列表失败")
}
list := make([]*dto.ExchangeOrderResponse, 0, len(orders))
for _, item := range orders {
list = append(list, s.toExchangeOrderResponse(item))
}
return &dto.ExchangeListResponse{List: list, Total: total, Page: page, PageSize: pageSize}, nil
}
func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse, error) {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeExchangeOrderNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单详情失败")
}
return s.toExchangeOrderResponse(order), nil
}
func (s *Service) Ship(ctx context.Context, id uint, req *dto.ExchangeShipRequest) (*dto.ExchangeOrderResponse, error) {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeExchangeOrderNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusPendingShip {
return nil, errors.New(errors.CodeExchangeStatusInvalid)
}
newAsset, err := s.resolveAssetByIdentifier(ctx, order.OldAssetType, req.NewIdentifier)
if err != nil {
return nil, err
}
if newAsset.AssetType != order.OldAssetType {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
if newAsset.AssetStatus != 1 {
return nil, errors.New(errors.CodeExchangeNewAssetNotInStock)
}
updates := map[string]any{
"new_asset_type": newAsset.AssetType,
"new_asset_id": newAsset.AssetID,
"new_asset_identifier": newAsset.Identifier,
"express_company": req.ExpressCompany,
"express_no": req.ExpressNo,
"migrate_data": req.MigrateData,
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}
if err = s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusPendingShip, constants.ExchangeStatusShipped, updates); err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeExchangeStatusInvalid)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新换货单发货状态失败")
}
return s.Get(ctx, id)
}
func (s *Service) Complete(ctx context.Context, id uint) error {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeOrderNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusShipped {
return errors.New(errors.CodeExchangeStatusInvalid)
}
updates := map[string]any{
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}
if order.MigrateData {
var migrationBalance int64
migrationBalance, err = s.executeMigration(ctx, order)
if err != nil {
return err
}
updates["migration_completed"] = true
updates["migration_balance"] = migrationBalance
}
if err = s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusShipped, constants.ExchangeStatusCompleted, updates); err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return errors.Wrap(errors.CodeDatabaseError, err, "确认换货完成失败")
}
return nil
}
func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRequest) error {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeOrderNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusPendingInfo && order.Status != constants.ExchangeStatusPendingShip {
return errors.New(errors.CodeExchangeStatusInvalid)
}
updates := map[string]any{
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}
if req != nil {
updates["remark"] = req.Remark
}
if err = s.exchangeStore.UpdateStatus(ctx, id, order.Status, constants.ExchangeStatusCancelled, updates); err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return errors.Wrap(errors.CodeDatabaseError, err, "取消换货失败")
}
return nil
}
func (s *Service) Renew(ctx context.Context, id uint) error {
order, err := s.exchangeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeOrderNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusCompleted {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if order.OldAssetType == constants.ExchangeAssetTypeIotCard {
var card model.IotCard
if err = tx.Where("id = ?", order.OldAssetID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询旧卡失败")
}
if card.AssetStatus != 3 {
return errors.New(errors.CodeExchangeAssetNotExchanged)
}
if err = tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
"generation": card.Generation + 1,
"asset_status": 1,
"accumulated_recharge": 0,
"first_commission_paid": false,
"accumulated_recharge_by_series": "{}",
"first_recharge_triggered_by_series": "{}",
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "重置旧卡转新状态失败")
}
if err = tx.Where("virtual_no = ?", card.VirtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
}
if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败")
}
shopTag := uint(0)
if card.ShopID != nil {
shopTag = *card.ShopID
}
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: 1, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
}
return nil
}
var device model.Device
if err = tx.Where("id = ?", order.OldAssetID).First(&device).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询旧设备失败")
}
if device.AssetStatus != 3 {
return errors.New(errors.CodeExchangeAssetNotExchanged)
}
if err = tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{
"generation": device.Generation + 1,
"asset_status": 1,
"accumulated_recharge": 0,
"first_commission_paid": false,
"accumulated_recharge_by_series": "{}",
"first_recharge_triggered_by_series": "{}",
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "重置旧设备转新状态失败")
}
if err = tx.Where("virtual_no = ?", device.VirtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
}
if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeDevice, device.ID).Delete(&model.AssetWallet{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败")
}
shopTag := uint(0)
if device.ShopID != nil {
shopTag = *device.ShopID
}
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeDevice, ResourceID: device.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: 1, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
}
return nil
})
}
func (s *Service) GetPending(ctx context.Context, identifier string) (*dto.ClientExchangePendingResponse, error) {
asset, err := s.resolveAssetByIdentifier(ctx, "", identifier)
if err != nil {
return nil, err
}
order, err := s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待处理换货单失败")
}
return &dto.ClientExchangePendingResponse{
ID: order.ID,
ExchangeNo: order.ExchangeNo,
Status: order.Status,
StatusText: exchangeStatusText(order.Status),
ExchangeReason: order.ExchangeReason,
CreatedAt: order.CreatedAt,
}, nil
}
func (s *Service) SubmitShippingInfo(ctx context.Context, id uint, req *dto.ClientShippingInfoRequest) error {
updates := map[string]any{
"recipient_name": req.RecipientName,
"recipient_phone": req.RecipientPhone,
"recipient_address": req.RecipientAddress,
"updated_at": time.Now(),
}
if err := s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, updates); err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return errors.Wrap(errors.CodeDatabaseError, err, "提交收货信息失败")
}
return nil
}
type resolvedExchangeAsset struct {
AssetType string
AssetID uint
Identifier string
VirtualNo string
AssetStatus int
ShopID *uint
Card *model.IotCard
Device *model.Device
}
func (s *Service) resolveAssetByIdentifier(ctx context.Context, expectedAssetType, identifier string) (*resolvedExchangeAsset, error) {
if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeDevice {
device, err := s.deviceStore.GetByIdentifier(ctx, identifier)
if err == nil {
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: identifier, VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}, nil
}
if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
}
if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeIotCard {
var card model.IotCard
query := s.db.WithContext(ctx).Where("virtual_no = ? OR iccid = ? OR msisdn = ?", identifier, identifier, identifier)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&card).Error; err == nil {
if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard {
return nil, errors.New(errors.CodeExchangeAssetTypeMismatch)
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: identifier, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: &card}, nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
}
return nil, errors.New(errors.CodeAssetNotFound)
}
func (s *Service) toExchangeOrderResponse(order *model.ExchangeOrder) *dto.ExchangeOrderResponse {
if order == nil {
return nil
}
var deletedAt *time.Time
if order.DeletedAt.Valid {
deletedAt = &order.DeletedAt.Time
}
return &dto.ExchangeOrderResponse{
ID: order.ID,
ExchangeNo: order.ExchangeNo,
OldAssetType: order.OldAssetType,
OldAssetID: order.OldAssetID,
OldAssetIdentifier: order.OldAssetIdentifier,
NewAssetType: order.NewAssetType,
NewAssetID: order.NewAssetID,
NewAssetIdentifier: order.NewAssetIdentifier,
RecipientName: order.RecipientName,
RecipientPhone: order.RecipientPhone,
RecipientAddress: order.RecipientAddress,
ExpressCompany: order.ExpressCompany,
ExpressNo: order.ExpressNo,
MigrateData: order.MigrateData,
MigrationCompleted: order.MigrationCompleted,
MigrationBalance: order.MigrationBalance,
ExchangeReason: order.ExchangeReason,
Remark: order.Remark,
Status: order.Status,
StatusText: exchangeStatusText(order.Status),
ShopID: order.ShopID,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
DeletedAt: deletedAt,
Creator: order.Creator,
Updater: order.Updater,
}
}
func exchangeStatusText(status int) string {
switch status {
case constants.ExchangeStatusPendingInfo:
return "待填写信息"
case constants.ExchangeStatusPendingShip:
return "待发货"
case constants.ExchangeStatusShipped:
return "已发货待确认"
case constants.ExchangeStatusCompleted:
return "已完成"
case constants.ExchangeStatusCancelled:
return "已取消"
default:
return "未知状态"
}
}

View File

@@ -0,0 +1,106 @@
package postgres
import (
"context"
"maps"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
)
type ExchangeOrderStore struct {
db *gorm.DB
}
func NewExchangeOrderStore(db *gorm.DB) *ExchangeOrderStore {
return &ExchangeOrderStore{db: db}
}
func (s *ExchangeOrderStore) Create(ctx context.Context, order *model.ExchangeOrder) error {
return s.db.WithContext(ctx).Create(order).Error
}
func (s *ExchangeOrderStore) GetByID(ctx context.Context, id uint) (*model.ExchangeOrder, error) {
var order model.ExchangeOrder
query := s.db.WithContext(ctx).Where("id = ?", id)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&order).Error; err != nil {
return nil, err
}
return &order, nil
}
func (s *ExchangeOrderStore) List(ctx context.Context, filters map[string]any, page, pageSize int) ([]*model.ExchangeOrder, int64, error) {
var orders []*model.ExchangeOrder
var total int64
query := s.db.WithContext(ctx).Model(&model.ExchangeOrder{})
query = middleware.ApplyShopFilter(ctx, query)
if status, ok := filters["status"].(int); ok && status > 0 {
query = query.Where("status = ?", status)
}
if identifier, ok := filters["identifier"].(string); ok && identifier != "" {
like := "%" + identifier + "%"
query = query.Where("old_asset_identifier LIKE ? OR new_asset_identifier LIKE ?", like, like)
}
if createdAtStart, ok := filters["created_at_start"].(time.Time); ok && !createdAtStart.IsZero() {
query = query.Where("created_at >= ?", createdAtStart)
}
if createdAtEnd, ok := filters["created_at_end"].(time.Time); ok && !createdAtEnd.IsZero() {
query = query.Where("created_at <= ?", createdAtEnd)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
offset := (page - 1) * pageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&orders).Error; err != nil {
return nil, 0, err
}
return orders, total, nil
}
func (s *ExchangeOrderStore) UpdateStatus(ctx context.Context, id uint, fromStatus, toStatus int, updates map[string]any) error {
values := make(map[string]any, len(updates)+1)
maps.Copy(values, updates)
values["status"] = toStatus
result := s.db.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("id = ? AND status = ?", id, fromStatus).
Updates(values)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (s *ExchangeOrderStore) FindActiveByOldAsset(ctx context.Context, assetType string, assetID uint) (*model.ExchangeOrder, error) {
var order model.ExchangeOrder
query := s.db.WithContext(ctx).
Where("old_asset_type = ? AND old_asset_id = ?", assetType, assetID).
Where("status IN ?", []int{constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, constants.ExchangeStatusShipped})
query = middleware.ApplyShopFilter(ctx, query)
if err := query.Order("id DESC").First(&order).Error; err != nil {
return nil, err
}
return &order, nil
}

View File

@@ -644,14 +644,14 @@ func (s *IotCardStore) applyStandaloneFilters(ctx context.Context, query *gorm.D
if isReplaced, ok := filters["is_replaced"].(bool); ok {
if isReplaced {
query = query.Where("id IN (?)",
s.db.WithContext(ctx).Table("tb_card_replacement_record").
Select("old_iot_card_id").
Where("deleted_at IS NULL"))
s.db.WithContext(ctx).Table("tb_exchange_order").
Select("old_asset_id").
Where("old_asset_type = ? AND status IN ? AND deleted_at IS NULL", constants.ExchangeAssetTypeIotCard, []int{constants.ExchangeStatusShipped, constants.ExchangeStatusCompleted}))
} else {
query = query.Where("id NOT IN (?)",
s.db.WithContext(ctx).Table("tb_card_replacement_record").
Select("old_iot_card_id").
Where("deleted_at IS NULL"))
s.db.WithContext(ctx).Table("tb_exchange_order").
Select("old_asset_id").
Where("old_asset_type = ? AND status IN ? AND deleted_at IS NULL", constants.ExchangeAssetTypeIotCard, []int{constants.ExchangeStatusShipped, constants.ExchangeStatusCompleted}))
}
}
if seriesID, ok := filters["series_id"].(uint); ok && seriesID > 0 {
@@ -836,7 +836,7 @@ func (s *IotCardStore) ListBySeriesID(ctx context.Context, seriesID uint) ([]*mo
func (s *IotCardStore) UpdateRechargeTrackingFields(ctx context.Context, cardID uint, accumulatedJSON, triggeredJSON string) error {
return s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ?", cardID).
Updates(map[string]interface{}{
Updates(map[string]any{
"accumulated_recharge_by_series": accumulatedJSON,
"first_recharge_triggered_by_series": triggeredJSON,
}).Error
@@ -961,8 +961,8 @@ func hashFilters(filters map[string]any) string {
h := fnv.New32a()
for _, k := range keys {
h.Write([]byte(k))
h.Write([]byte(fmt.Sprint(filters[k])))
_, _ = h.Write([]byte(k))
_, _ = fmt.Fprint(h, filters[k])
}
return fmt.Sprintf("%08x", h.Sum32())
}

View File

@@ -0,0 +1,30 @@
package postgres
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"gorm.io/gorm"
)
type ResourceTagStore struct {
db *gorm.DB
}
func NewResourceTagStore(db *gorm.DB) *ResourceTagStore {
return &ResourceTagStore{db: db}
}
func (s *ResourceTagStore) ListByResource(ctx context.Context, resourceType string, resourceID uint) ([]*model.ResourceTag, error) {
var list []*model.ResourceTag
if err := s.db.WithContext(ctx).
Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).
Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (s *ResourceTagStore) Create(ctx context.Context, item *model.ResourceTag) error {
return s.db.WithContext(ctx).Create(item).Error
}