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:
@@ -26,6 +26,13 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
|
||||
handlers := openapi.BuildDocHandlers()
|
||||
handlers.AssetLifecycle = admin.NewAssetLifecycleHandler(nil)
|
||||
handlers.ClientAuth = apphandler.NewClientAuthHandler(nil, nil)
|
||||
handlers.ClientAsset = apphandler.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.ClientWallet = apphandler.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.ClientOrder = apphandler.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.ClientExchange = apphandler.NewClientExchangeHandler(nil)
|
||||
handlers.ClientRealname = apphandler.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.ClientDevice = apphandler.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.AdminExchange = admin.NewExchangeHandler(nil, nil)
|
||||
|
||||
// 4. 注册所有路由到文档生成器
|
||||
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
||||
|
||||
@@ -35,6 +35,13 @@ func generateAdminDocs(outputPath string) error {
|
||||
handlers := openapi.BuildDocHandlers()
|
||||
handlers.AssetLifecycle = admin.NewAssetLifecycleHandler(nil)
|
||||
handlers.ClientAuth = apphandler.NewClientAuthHandler(nil, nil)
|
||||
handlers.ClientAsset = apphandler.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.ClientWallet = apphandler.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.ClientOrder = apphandler.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.ClientExchange = apphandler.NewClientExchangeHandler(nil)
|
||||
handlers.ClientRealname = apphandler.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.ClientDevice = apphandler.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||
handlers.AdminExchange = admin.NewExchangeHandler(nil, nil)
|
||||
|
||||
// 4. 注册所有路由到文档生成器
|
||||
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
94
docs/client-exchange-system/功能总结.md
Normal file
94
docs/client-exchange-system/功能总结.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 客户端换货系统功能总结
|
||||
|
||||
## 1. 功能概述
|
||||
|
||||
本次实现完成了客户端换货系统的后台与客户端闭环能力,覆盖「后台建单 → 客户端填写收货信息 → 后台发货 → 后台确认完成(可选全量迁移) → 旧资产转新」完整流程。
|
||||
|
||||
## 2. 数据模型与迁移
|
||||
|
||||
- 新增 `tb_exchange_order` 表,承载换货生命周期全量字段:旧/新资产、收货信息、物流信息、迁移状态、业务状态、多租户字段。
|
||||
- 保留历史能力:将旧表 `tb_card_replacement_record` 重命名为 `tb_card_replacement_record_legacy`。
|
||||
- 新增迁移文件:
|
||||
- `000085_add_exchange_order.up/down.sql`
|
||||
- `000086_rename_card_replacement_to_legacy.up/down.sql`
|
||||
|
||||
## 3. 后端实现
|
||||
|
||||
### 3.1 Store 层
|
||||
|
||||
- 新增 `ExchangeOrderStore`:
|
||||
- 创建、按 ID 查询、分页列表查询
|
||||
- 条件状态流转更新(`WHERE status = fromStatus`)
|
||||
- 按旧资产查询进行中换货单(状态 `1/2/3`)
|
||||
|
||||
- 新增 `ResourceTagStore`:用于资源标签复制。
|
||||
|
||||
### 3.2 Service 层
|
||||
|
||||
- 新增 `internal/service/exchange/service.go`:
|
||||
- H1 创建换货单(资产存在校验、进行中校验、单号生成、状态初始化)
|
||||
- H2 列表查询
|
||||
- H3 详情查询
|
||||
- H4 发货(状态校验、同类型校验、新资产在库校验、物流与新资产快照写入)
|
||||
- H5 确认完成(状态校验,可选全量迁移)
|
||||
- H6 取消(仅允许 `1/2 -> 5`)
|
||||
- H7 转新(校验已换货状态、`generation+1`、状态重置、清理绑定、创建新钱包)
|
||||
- G1 查询待处理换货单
|
||||
- G2 提交收货信息(`1 -> 2`)
|
||||
|
||||
- 新增 `internal/service/exchange/migration.go`:
|
||||
- 单事务迁移实现
|
||||
- 钱包余额迁移并写入迁移流水
|
||||
- 套餐使用记录迁移(`tb_package_usage`)
|
||||
- 套餐日记录联动更新(`tb_package_usage_daily_record`)
|
||||
- 累计充值/首充字段复制(旧资产 -> 新资产)
|
||||
- 标签复制(`tb_resource_tag`)
|
||||
- 客户绑定 `virtual_no` 更新(`tb_personal_customer_device`)
|
||||
- 旧资产状态置为已换货(`asset_status=3`)
|
||||
- 换货单迁移结果回写(`migration_completed`、`migration_balance`)
|
||||
|
||||
## 4. Handler 与路由
|
||||
|
||||
### 4.1 后台换货接口
|
||||
|
||||
- 新增 `internal/handler/admin/exchange.go`
|
||||
- 新增 `internal/routes/exchange.go`
|
||||
- 注册接口(标签:`换货管理`):
|
||||
- `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`
|
||||
|
||||
### 4.2 客户端换货接口
|
||||
|
||||
- 新增 `internal/handler/app/client_exchange.go`
|
||||
- 在 `internal/routes/personal.go` 注册:
|
||||
- `GET /api/c/v1/exchange/pending`
|
||||
- `POST /api/c/v1/exchange/:id/shipping-info`
|
||||
|
||||
## 5. 兼容与替换
|
||||
|
||||
- `iot_card_store.go` 的 `is_replaced` 过滤逻辑已切换至 `tb_exchange_order`。
|
||||
- 业务主流程不再依赖旧换卡表(仅模型与 legacy 表保留用于历史数据)。
|
||||
|
||||
## 6. 启动装配与文档生成
|
||||
|
||||
已完成换货模块在以下位置的全链路接入:
|
||||
|
||||
- `internal/bootstrap/types.go`
|
||||
- `internal/bootstrap/stores.go`
|
||||
- `internal/bootstrap/services.go`
|
||||
- `internal/bootstrap/handlers.go`
|
||||
- `internal/routes/admin.go`
|
||||
- `pkg/openapi/handlers.go`
|
||||
- `cmd/api/docs.go`
|
||||
- `cmd/gendocs/main.go`
|
||||
|
||||
## 7. 验证结果
|
||||
|
||||
- 已执行:`go build ./...`,编译通过。
|
||||
- 已执行:数据库迁移 `make migrate-up`,版本到 `86`。
|
||||
- 已完成:变更文件 LSP 诊断检查(无 error 级问题)。
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
131
internal/handler/admin/exchange.go
Normal file
131
internal/handler/admin/exchange.go
Normal 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)
|
||||
}
|
||||
57
internal/handler/app/client_exchange.go
Normal file
57
internal/handler/app/client_exchange.go
Normal 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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
104
internal/model/dto/exchange_dto.go
Normal file
104
internal/model/dto/exchange_dto.go
Normal 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:"创建时间"`
|
||||
}
|
||||
65
internal/model/exchange_order.go
Normal file
65
internal/model/exchange_order.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
66
internal/routes/exchange.go
Normal file
66
internal/routes/exchange.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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{},
|
||||
})
|
||||
}
|
||||
|
||||
243
internal/service/exchange/migration.go
Normal file
243
internal/service/exchange/migration.go
Normal 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
|
||||
}
|
||||
487
internal/service/exchange/service.go
Normal file
487
internal/service/exchange/service.go
Normal 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 "未知状态"
|
||||
}
|
||||
}
|
||||
106
internal/store/postgres/exchange_order_store.go
Normal file
106
internal/store/postgres/exchange_order_store.go
Normal 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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
30
internal/store/postgres/resource_tag_store.go
Normal file
30
internal/store/postgres/resource_tag_store.go
Normal 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
|
||||
}
|
||||
1
migrations/000085_add_exchange_order.down.sql
Normal file
1
migrations/000085_add_exchange_order.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS tb_exchange_order;
|
||||
75
migrations/000085_add_exchange_order.up.sql
Normal file
75
migrations/000085_add_exchange_order.up.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
CREATE TABLE IF NOT EXISTS tb_exchange_order (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
updater BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
exchange_no VARCHAR(50) NOT NULL,
|
||||
|
||||
old_asset_type VARCHAR(20) NOT NULL,
|
||||
old_asset_id BIGINT NOT NULL,
|
||||
old_asset_identifier VARCHAR(100) NOT NULL,
|
||||
|
||||
new_asset_type VARCHAR(20) DEFAULT '',
|
||||
new_asset_id BIGINT,
|
||||
new_asset_identifier VARCHAR(100) DEFAULT '',
|
||||
|
||||
recipient_name VARCHAR(50) DEFAULT '',
|
||||
recipient_phone VARCHAR(20) DEFAULT '',
|
||||
recipient_address TEXT DEFAULT '',
|
||||
|
||||
express_company VARCHAR(100) DEFAULT '',
|
||||
express_no VARCHAR(100) DEFAULT '',
|
||||
|
||||
migrate_data BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
migration_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
migration_balance BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
exchange_reason VARCHAR(100) NOT NULL,
|
||||
remark TEXT,
|
||||
status INT NOT NULL DEFAULT 1,
|
||||
|
||||
shop_id BIGINT
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_exchange_order_no
|
||||
ON tb_exchange_order (exchange_no)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exchange_order_old_asset
|
||||
ON tb_exchange_order (old_asset_type, old_asset_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exchange_order_status
|
||||
ON tb_exchange_order (status)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exchange_order_shop_id
|
||||
ON tb_exchange_order (shop_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exchange_order_created_at
|
||||
ON tb_exchange_order (created_at DESC);
|
||||
|
||||
COMMENT ON TABLE tb_exchange_order IS '换货订单表';
|
||||
COMMENT ON COLUMN tb_exchange_order.exchange_no IS '换货单号(EXC+日期+随机数)';
|
||||
COMMENT ON COLUMN tb_exchange_order.old_asset_type IS '旧资产类型(iot_card/device)';
|
||||
COMMENT ON COLUMN tb_exchange_order.old_asset_id IS '旧资产ID';
|
||||
COMMENT ON COLUMN tb_exchange_order.old_asset_identifier IS '旧资产标识符(ICCID/虚拟号)';
|
||||
COMMENT ON COLUMN tb_exchange_order.new_asset_type IS '新资产类型(iot_card/device)';
|
||||
COMMENT ON COLUMN tb_exchange_order.new_asset_id IS '新资产ID';
|
||||
COMMENT ON COLUMN tb_exchange_order.new_asset_identifier IS '新资产标识符(ICCID/虚拟号)';
|
||||
COMMENT ON COLUMN tb_exchange_order.recipient_name IS '收件人姓名';
|
||||
COMMENT ON COLUMN tb_exchange_order.recipient_phone IS '收件人电话';
|
||||
COMMENT ON COLUMN tb_exchange_order.recipient_address IS '收货地址';
|
||||
COMMENT ON COLUMN tb_exchange_order.express_company IS '快递公司';
|
||||
COMMENT ON COLUMN tb_exchange_order.express_no IS '快递单号';
|
||||
COMMENT ON COLUMN tb_exchange_order.migrate_data IS '是否执行全量迁移';
|
||||
COMMENT ON COLUMN tb_exchange_order.migration_completed IS '迁移是否已完成';
|
||||
COMMENT ON COLUMN tb_exchange_order.migration_balance IS '迁移转移金额(分)';
|
||||
COMMENT ON COLUMN tb_exchange_order.exchange_reason IS '换货原因';
|
||||
COMMENT ON COLUMN tb_exchange_order.remark IS '备注';
|
||||
COMMENT ON COLUMN tb_exchange_order.status IS '换货状态 1-待填写信息 2-待发货 3-已发货待确认 4-已完成 5-已取消';
|
||||
COMMENT ON COLUMN tb_exchange_order.shop_id IS '所属店铺ID';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tb_card_replacement_record_legacy RENAME TO tb_card_replacement_record;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tb_card_replacement_record RENAME TO tb_card_replacement_record_legacy;
|
||||
2
openspec/changes/client-exchange-system/.openspec.yaml
Normal file
2
openspec/changes/client-exchange-system/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-18
|
||||
149
openspec/changes/client-exchange-system/design.md
Normal file
149
openspec/changes/client-exchange-system/design.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# 设计文档:客户端换货系统(client-exchange-system)
|
||||
|
||||
## 背景与上下文
|
||||
|
||||
现有换卡能力基于 `CardReplacementRecord`,仅覆盖“老卡→新卡”的窄场景,无法支撑本次目标中的完整换货闭环(后台发起、客户端填收货、后台发货、确认完成、可选全量迁移、旧资产转新再销售)。
|
||||
|
||||
当前主要问题:
|
||||
|
||||
1. **模型能力不足**(`internal/model/card_replacement.go:11-71`)
|
||||
- 只支持卡换卡,不支持设备换设备。
|
||||
- 缺少客户端收货地址、后台物流信息、迁移结果字段。
|
||||
- 状态机不匹配本次流程(待填写→待发货→已发货待确认→已完成/已取消)。
|
||||
|
||||
2. **历史代码字段不一致风险**(`internal/store/postgres/iot_card_store.go:644-655`)
|
||||
- 现有查询使用 `old_iot_card_id` 维度过滤换卡记录,但旧模型字段命名是 `old_card_id`,存在语义/列名不一致隐患。
|
||||
- `is_replaced` 逻辑依赖旧表,不适配新换货单模型。
|
||||
|
||||
3. **旧模型未纳入统一迁移体系**
|
||||
- `CardReplacementRecord` 没有持续参与当前主线 AutoMigrate 维护,演进风险高。
|
||||
|
||||
4. **资产迁移链路涉及多模型联动,旧方案无法表达**
|
||||
- 钱包与流水:`internal/model/asset_wallet.go:9-35`(`resource_type + resource_id`)
|
||||
- 套餐使用:`internal/model/package.go:57-87`
|
||||
- 标签:`internal/model/tag.go:25-41`
|
||||
- 客户设备绑定:`internal/model/personal_customer_device.go:9-23`
|
||||
- 设备卡绑定:`internal/model/device_sim_binding.go:9-24`
|
||||
- 分佣记录:`internal/model/commission.go:9-30`
|
||||
- 流量明细:`internal/model/data_usage.go:7-23`
|
||||
- 卡累计字段:`internal/model/iot_card.go:41-44`(`FirstCommissionPaid`、`AccumulatedRecharge`、`AccumulatedRechargeBySeriesJSON`、`FirstRechargeTriggeredBySeriesJSON`)
|
||||
|
||||
5. **模块接入需遵循统一 Bootstrap 装配模式**
|
||||
- 参考 `internal/bootstrap/handlers.go:12-62`、`internal/bootstrap/types.go:13-60`。
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
### Goals
|
||||
|
||||
1. 提供完整换货生命周期能力:
|
||||
- 后台 7 个接口(H1~H7)
|
||||
- 客户端 2 个接口(G1~G2)
|
||||
2. 在 H5 确认完成时支持可选“全量迁移”(11 张表规则)。
|
||||
3. 支持旧资产“转新”再销售(generation+1、状态重置、历史隔离)。
|
||||
4. 替换旧换卡模型引用,统一到 ExchangeOrder。
|
||||
|
||||
### Non-Goals
|
||||
|
||||
1. 不对接第三方物流轨迹查询(仅记录物流公司/单号)。
|
||||
2. 不实现主动消息推送(客户端通过 G1 轮询换货通知)。
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
### 决策 1:ExchangeOrder 模型设计
|
||||
|
||||
引入新模型 `ExchangeOrder`,作为换货生命周期唯一事实来源,字段覆盖:
|
||||
|
||||
- 基础字段:`gorm.Model + BaseModel`
|
||||
- 单号:`exchange_no`
|
||||
- 旧资产快照:`old_asset_type`、`old_asset_id`、`old_asset_identifier`
|
||||
- 新资产快照:`new_asset_type`、`new_asset_id`、`new_asset_identifier`
|
||||
- 收货信息:`recipient_name`、`recipient_phone`、`recipient_address`
|
||||
- 物流信息:`express_company`、`express_no`
|
||||
- 迁移结果:`migrate_data`、`migration_completed`、`migration_balance`
|
||||
- 业务信息:`exchange_reason`、`remark`、`status`
|
||||
- 多租户:`shop_id`
|
||||
|
||||
换货单号生成规则:`EXC` + 日期 + 随机数(示例:`EXC20260319XXXXXX`)。
|
||||
|
||||
### 决策 2:状态机由 Service 层强校验
|
||||
|
||||
`status` 采用 int 常量:
|
||||
|
||||
- 1 待填写信息
|
||||
- 2 待发货
|
||||
- 3 已发货待确认
|
||||
- 4 已完成
|
||||
- 5 已取消
|
||||
|
||||
状态流转在 Service 层校验,不使用数据库触发器。理由:
|
||||
|
||||
1. 业务规则集中在 Go 代码,便于复用和审计。
|
||||
2. 避免跨环境数据库触发器差异。
|
||||
3. 更易与错误码体系、权限体系协同。
|
||||
|
||||
### 决策 3:发货时执行同类型资产校验
|
||||
|
||||
在 H4 发货阶段强制校验:
|
||||
|
||||
1. `new_asset_type == old_asset_type`(卡换卡 / 设备换设备)。
|
||||
2. 新资产必须 `asset_status=1`(在库)。
|
||||
|
||||
该校验放在“发货”而非“创建”,因为创建时允许先立单、后备货。
|
||||
|
||||
### 决策 4:全量迁移使用单一大事务(11 张表)
|
||||
|
||||
H5 在 `migrate_data=true` 时,使用**一个数据库事务**完成 11 张表相关操作。理由:
|
||||
|
||||
1. 迁移一致性优先,必须保证“要么全成功,要么全失败”。
|
||||
2. 换货属于低频运营操作,非高并发核心交易路径。
|
||||
3. 单资产迁移涉及行数有限,可接受事务时间。
|
||||
|
||||
补充规则:设备换设备时,不迁移 `DeviceSimBinding`(新设备视为自带新卡体系)。
|
||||
|
||||
### 决策 5:转新采用 generation 隔离历史
|
||||
|
||||
H7 转新时:
|
||||
|
||||
1. `generation = generation + 1`
|
||||
2. 不删除旧代际历史数据(订单、充值、分佣、流量等)
|
||||
3. 创建新空钱包(新 `wallet_id` 天然隔离流水)
|
||||
4. 清除累计充值/首充触发状态
|
||||
5. 清除客户绑定关系
|
||||
|
||||
通过“新代际 + 新钱包”实现可回收再销售,同时不破坏历史可追溯。
|
||||
|
||||
### 决策 6:旧模型降级为 legacy,不回灌迁移
|
||||
|
||||
`CardReplacementRecord` 对应表改名为 `tb_card_replacement_record_legacy`,但**不迁移历史数据到新表**。理由:
|
||||
|
||||
1. 旧数据量小,保留查询价值即可。
|
||||
2. 历史数据结构与新模型语义不完全一致,强行回灌成本高且收益低。
|
||||
|
||||
`iot_card_store.go` 中 `is_replaced` 过滤逻辑改为查询 `ExchangeOrder`,不再依赖旧表。
|
||||
|
||||
### 决策 7:依托现有多租户 Callback 自动过滤
|
||||
|
||||
`ExchangeOrder` 增加 `shop_id` 字段,直接接入现有 GORM 数据权限 Callback,避免重复实现权限 where 条件。
|
||||
|
||||
## 风险与权衡
|
||||
|
||||
1. **[风险] 全量迁移事务锁表时间增长**
|
||||
- 权衡:换货低频,且单次仅操作单资产关联记录,影响可接受。
|
||||
|
||||
2. **[风险] 转新后旧客户仍持有旧虚拟号认知**
|
||||
- 权衡:`PersonalCustomerDevice` 绑定会清除,旧客户再次登录会被要求重新绑定,避免继续访问新代际资产。
|
||||
|
||||
3. **[风险] 设备换设备不迁移 DeviceSimBinding 造成“看起来少迁移”**
|
||||
- 权衡:这是显式设计决策;新设备按“新硬件+新卡”交付,旧设备卡绑定保留历史关系。
|
||||
|
||||
4. **[风险] 迁移期 CMP 与 Gateway 状态观测不一致**
|
||||
- 权衡:本次迁移仅操作 CMP 数据库,不调用 Gateway;运营商侧状态由新资产实际使用逐步收敛。
|
||||
|
||||
## 迁移计划
|
||||
|
||||
1. 新建 `tb_exchange_order` 表。
|
||||
2. 将 `tb_card_replacement_record` 改名为 `tb_card_replacement_record_legacy`。
|
||||
3. 代码层替换:
|
||||
- Store/Service/Handler 查询改用 ExchangeOrder
|
||||
- `is_replaced` 等旧逻辑改为新表判定
|
||||
4. 在 bootstrap、routes、docs 生成器中注册新 Handler(含 `cmd/api/docs.go` 与 `cmd/gendocs/main.go`)。
|
||||
162
openspec/changes/client-exchange-system/proposal.md
Normal file
162
openspec/changes/client-exchange-system/proposal.md
Normal file
@@ -0,0 +1,162 @@
|
||||
## Why
|
||||
|
||||
现有 `CardReplacementRecord` 模型仅支持简单换卡,无法满足完整换货需求:缺少收货地址、快递信息、设备换货、全量数据迁移等功能。客户端换货场景中,后台发起换货 → 客户端收到通知填写收货信息 → 后台发货+确认完成(含全量迁移)→ 旧资产可"转新"重新销售,是一个跨后台/客户端的完整业务闭环。
|
||||
|
||||
**前置依赖**:提案 0(`asset_status`/`generation` 字段已就位)、提案 1(客户端认证)。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 新增模型
|
||||
|
||||
- **ExchangeOrder(换货单)**:完整的换货生命周期模型,包含旧/新资产信息、收货地址、物流信息、迁移状态。状态机:`1-待填写信息 → 2-待发货 → 3-已发货待确认 → 4-已完成`,1 或 2 时可取消(→5)
|
||||
|
||||
### 删除旧模型
|
||||
|
||||
- **CardReplacementRecord**:表改名为 `tb_card_replacement_record_legacy`,代码引用替换为 ExchangeOrder
|
||||
|
||||
### 后台换货管理(模块 H,7 个接口)
|
||||
|
||||
- **H1 发起换货** `POST /api/admin/exchanges`:验证资产无进行中换货单,创建 status=1
|
||||
- **H2 换货列表** `GET /api/admin/exchanges`:支持状态筛选、资产标识符搜索、时间范围
|
||||
- **H3 换货详情** `GET /api/admin/exchanges/:id`
|
||||
- **H4 发货** `POST /api/admin/exchanges/:id/ship`:填写物流信息+新资产标识符,验证 status=2、同类型资产、新资产 asset_status=1(在库)
|
||||
- **H5 确认完成** `POST /api/admin/exchanges/:id/complete`:验证 status=3,如 migrate_data=true 则执行全量迁移(11 张表事务内操作),旧资产 asset_status→3
|
||||
- **H6 取消换货** `POST /api/admin/exchanges/:id/cancel`:验证 status IN (1,2),已发货不可取消
|
||||
- **H7 旧资产转新** `POST /api/admin/exchanges/:id/renew`:旧资产 asset_status 从 3→1,generation+1,清除客户绑定和累计状态,创建新空钱包
|
||||
|
||||
### 客户端换货(模块 G,2 个接口)
|
||||
|
||||
- **G1 查询换货通知** `GET /api/c/v1/exchange/pending?identifier=xxx`:查是否有进行中的换货单
|
||||
- **G2 填写收货信息** `POST /api/c/v1/exchange/:id/shipping-info`:验证 status=1,更新收货信息,status→2
|
||||
|
||||
### 换货状态机
|
||||
|
||||
```
|
||||
后台发起换货
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 1-待填写信息 │ ←── ExchangeOrder 创建
|
||||
│ (等待客户端填写) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
客户端填写收货信息 [G2]
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 2-待发货 │
|
||||
│ (等待后台填写物流) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
后台发货 [H4] (填物流+新资产)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 3-已发货待确认 │
|
||||
│ (等待后台确认完成) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
后台确认完成 [H5]
|
||||
(可选: 全量迁移)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 4-已完成 │
|
||||
└─────────────────────┘
|
||||
|
||||
取消: status=1 或 2 时可取消 → 5-已取消
|
||||
已发货(status=3)后不可取消
|
||||
```
|
||||
|
||||
### 全量迁移流程(H5 确认完成时触发)
|
||||
|
||||
```
|
||||
确认完成 (migrate_data=true)
|
||||
│
|
||||
▼
|
||||
事务开始
|
||||
│
|
||||
├── 1. 钱包余额转移
|
||||
│ 旧 AssetWallet.Balance → 新 AssetWallet
|
||||
│ 生成迁移流水 AssetWalletTransaction
|
||||
│
|
||||
├── 2. 生效中套餐关联新资产
|
||||
│ PackageUsage WHERE iot_card_id/device_id=旧 AND status IN (生效中)
|
||||
│ → UPDATE iot_card_id/device_id = 新
|
||||
│
|
||||
├── 3. 累计充值/首充状态迁移
|
||||
│ IotCard/Device 的 AccumulatedRecharge/FirstCommissionPaid
|
||||
│ 等字段复制到新资产
|
||||
│
|
||||
├── 4. 标签复制
|
||||
│ ResourceTag WHERE resource_type=? AND resource_id=旧
|
||||
│ → 为新资产创建相同标签
|
||||
│
|
||||
├── 5. 个人客户绑定更新
|
||||
│ PersonalCustomerDevice WHERE virtual_no=旧虚拟号
|
||||
│ → UPDATE virtual_no = 新虚拟号
|
||||
│
|
||||
├── 6. 旧资产标记
|
||||
│ 旧资产 asset_status → 3(已换货)
|
||||
│
|
||||
└── 7. 记录迁移信息
|
||||
ExchangeOrder: migration_completed=true,
|
||||
migration_balance=转移金额
|
||||
│
|
||||
▼
|
||||
事务提交
|
||||
│
|
||||
▼
|
||||
ExchangeOrder status → 4(已完成)
|
||||
|
||||
注意:
|
||||
- 设备换设备时不迁移 DeviceSimBinding(卡绑定关系)
|
||||
- 新设备自带新的 SIM 卡,旧设备的卡绑定保持不变
|
||||
- 保留不修改的表: tb_order, tb_commission, tb_data_usage_record,
|
||||
tb_asset_recharge_record(历史记录保留,通过 generation 隔离)
|
||||
```
|
||||
|
||||
### 转新流程(H7)
|
||||
|
||||
```
|
||||
旧资产 (asset_status=3 已换货)
|
||||
│
|
||||
▼
|
||||
POST /api/admin/exchanges/:id/renew
|
||||
│
|
||||
├── 1. asset_status: 3 → 1(在库)
|
||||
├── 2. generation: +1(进入新世代)
|
||||
├── 3. 清除: 累计充值状态、首充触发状态
|
||||
├── 4. 清除: PersonalCustomerDevice 绑定
|
||||
├── 5. 创建新空钱包(新 wallet_id)
|
||||
└── 6. 不删除历史数据(通过 generation 隔离)
|
||||
│
|
||||
▼
|
||||
旧资产可重新销售给新客户
|
||||
新客户查询时按当前 generation 过滤
|
||||
看不到旧周期数据
|
||||
```
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `exchange-order-model`:ExchangeOrder 模型定义、状态机、状态常量、换货单号生成规则
|
||||
- `exchange-admin-management`:后台换货管理 CRUD(H1~H3)、发货(H4,含同类型资产校验+新资产在库校验)、确认完成(H5,含全量迁移事务)、取消(H6)、转新(H7,含 generation 自增+状态重置)
|
||||
- `exchange-data-migration`:全量迁移逻辑,11 张数据表的事务内操作规则,设备不迁移卡绑定的特殊规则
|
||||
- `exchange-client-notification`:客户端换货通知查询(G1)、收货信息填写(G2)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `iot-card`:IotCard 新增换货相关行为——`asset_status=3` 标记、转新时 generation 自增+状态重置
|
||||
- `device`:Device 同上
|
||||
- `personal-customer`:PersonalCustomerDevice 绑定关系在换货迁移时更新虚拟号
|
||||
- `card-replacement`:**REMOVED** — CardReplacementRecord 模型废弃,表改名为 legacy,代码引用替换为 ExchangeOrder
|
||||
|
||||
## Impact
|
||||
|
||||
- **新增文件**:`internal/model/exchange_order.go`(模型);`internal/handler/admin/exchange.go`(后台 Handler);`internal/handler/app/client_exchange.go`(客户端 Handler);`internal/service/exchange/service.go`(Service,含迁移逻辑);`internal/store/postgres/exchange_order_store.go`(Store);DTO 文件;迁移文件;常量和错误码
|
||||
- **修改文件**:`internal/model/card_replacement.go`(删除或标记废弃);`internal/store/postgres/iot_card_store.go`(移除 `is_replaced` 过滤改为查新表);`internal/model/system.go`(AutoMigrate 移除旧模型+注册新模型);`internal/routes/`(新增后台+客户端路由);`internal/bootstrap/`(注册新模块);`cmd/api/docs.go` + `cmd/gendocs/main.go`(文档生成器)
|
||||
- **新增 API 路由**:后台 `/api/admin/exchanges/` 下 7 个端点 + 客户端 `/api/c/v1/exchange/` 下 2 个端点
|
||||
- **数据库变更**:新建 `tb_exchange_order` 表;旧表 `tb_card_replacement_record` 改名为 `tb_card_replacement_record_legacy`
|
||||
- **全量迁移涉及 11 张表**:`tb_asset_wallet`、`tb_asset_wallet_transaction`、`tb_asset_recharge_record`、`tb_package_usage`、`tb_package_usage_daily_record`、`tb_order`、`tb_commission`、`tb_data_usage_record`、`tb_resource_tag`、`tb_personal_customer_device`、`tb_iot_card`/`tb_device`
|
||||
@@ -0,0 +1,31 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 废弃旧换卡模型能力
|
||||
|
||||
系统 MUST 废弃 `CardReplacementRecord` 作为主业务能力,原因是其仅覆盖卡换卡且缺少收货信息、物流信息、设备换货与全量迁移能力,无法满足当前换货闭环需求。
|
||||
|
||||
#### Scenario: 新换货流程不再写入旧模型
|
||||
- **WHEN** 执行任意新换货流程(H1~H7、G1~G2)
|
||||
- **THEN** 系统 MUST 仅读写 `ExchangeOrder`,不再创建 `CardReplacementRecord` 新记录
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 旧表迁移为 legacy 保留查询
|
||||
|
||||
系统 SHALL 将 `tb_card_replacement_record` 改名为 `tb_card_replacement_record_legacy`,仅用于历史查询保留。
|
||||
|
||||
系统 MUST NOT 将 legacy 数据回灌到 `tb_exchange_order`。
|
||||
|
||||
#### Scenario: legacy 数据保留但不参与新流程
|
||||
- **WHEN** 运营查询历史老换卡记录
|
||||
- **THEN** 系统可从 legacy 表读取历史数据,但新换货流程 SHALL 不依赖该表
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 旧代码引用替换
|
||||
|
||||
系统 MUST 将旧换卡引用替换为 `ExchangeOrder`,包括 `iot_card_store.go` 中 `is_replaced` 过滤逻辑。
|
||||
|
||||
#### Scenario: is_replaced 基于新换货单判定
|
||||
- **WHEN** 查询 IoT 卡并使用 `is_replaced=true` 过滤
|
||||
- **THEN** 系统 MUST 基于 `ExchangeOrder` 状态判定是否已发生换货,而非 legacy 表
|
||||
24
openspec/changes/client-exchange-system/specs/device/spec.md
Normal file
24
openspec/changes/client-exchange-system/specs/device/spec.md
Normal file
@@ -0,0 +1,24 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 设备换货状态语义扩展
|
||||
|
||||
系统 SHALL 将 `asset_status=3` 定义为“已换货”,用于标记已被换出的旧设备资产。
|
||||
|
||||
#### Scenario: 换货完成后旧设备标记
|
||||
- **WHEN** H5 确认完成且旧资产为设备
|
||||
- **THEN** 系统 MUST 将旧设备 `asset_status` 更新为 `3`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备转新重置规则
|
||||
|
||||
系统 SHALL 在 H7 转新时对设备执行以下重置:
|
||||
- `generation = generation + 1`
|
||||
- `asset_status = 1`(在库)
|
||||
- 清空累计充值与首充触发相关状态
|
||||
- 清除个人客户绑定关系
|
||||
- 创建新空钱包并与新代际设备关联
|
||||
|
||||
#### Scenario: 转新后设备可重新销售
|
||||
- **WHEN** 对已换货设备执行转新
|
||||
- **THEN** 系统 MUST 使该设备进入新代际并恢复在库可售
|
||||
@@ -0,0 +1,121 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: H1 发起换货单
|
||||
|
||||
系统 SHALL 提供 `POST /api/admin/exchanges`(需后台认证 `Auth=true`),用于发起换货单。
|
||||
|
||||
请求体 MUST 包含:`old_asset_type`、`old_identifier`、`exchange_reason`,可选 `remark`。
|
||||
|
||||
系统 MUST 校验:
|
||||
- 旧资产存在且当前用户有权限
|
||||
- 同一资产不存在进行中的换货单(`status IN (1,2,3)`)
|
||||
|
||||
成功响应 SHALL 返回新建换货单信息(含 `id`、`exchange_no`、`status=1`)。
|
||||
|
||||
错误响应 MUST 至少包含:参数错误、资产不存在或无权限、存在进行中换货单。
|
||||
|
||||
#### Scenario: 资产已有进行中换货单
|
||||
- **WHEN** 后台为同一资产重复发起换货
|
||||
- **THEN** 系统 MUST 拒绝创建并返回“存在进行中的换货单”
|
||||
|
||||
---
|
||||
|
||||
### Requirement: H2 换货单列表
|
||||
|
||||
系统 SHALL 提供 `GET /api/admin/exchanges`(`Auth=true`),支持分页与条件查询。
|
||||
|
||||
查询条件 SHOULD 支持:`status`、`identifier`(资产标识搜索)、`created_at_start`、`created_at_end`、分页参数。
|
||||
|
||||
响应 SHALL 返回列表与分页元数据。
|
||||
|
||||
#### Scenario: 按状态查询待发货单
|
||||
- **WHEN** 运营查询 `status=2`
|
||||
- **THEN** 系统返回所有待发货换货单并按创建时间倒序
|
||||
|
||||
---
|
||||
|
||||
### Requirement: H3 换货单详情
|
||||
|
||||
系统 SHALL 提供 `GET /api/admin/exchanges/:id`(`Auth=true`)查询换货单详情。
|
||||
|
||||
响应 MUST 返回旧/新资产信息、收货信息、物流信息、迁移状态信息。
|
||||
|
||||
错误响应 MUST 至少包含:换货单不存在或无权限。
|
||||
|
||||
#### Scenario: 查询不存在换货单
|
||||
- **WHEN** 查询不存在的换货单 ID
|
||||
- **THEN** 系统 MUST 返回“资源不存在或无权限”
|
||||
|
||||
---
|
||||
|
||||
### Requirement: H4 发货
|
||||
|
||||
系统 SHALL 提供 `POST /api/admin/exchanges/:id/ship`(`Auth=true`)。
|
||||
|
||||
请求体 MUST 包含:`express_company`、`express_no`、`new_identifier`、`migrate_data`。
|
||||
|
||||
系统 MUST 校验:
|
||||
- 当前状态必须为 `2`
|
||||
- 新旧资产类型必须一致(卡换卡/设备换设备)
|
||||
- 新资产必须 `asset_status=1`(在库)
|
||||
|
||||
成功后 SHALL 更新新资产信息、物流信息并将状态改为 `3`。
|
||||
|
||||
错误响应 MUST 至少包含:非法状态、资产类型不匹配、新资产非在库、资产不存在或无权限。
|
||||
|
||||
#### Scenario: 新资产类型不一致
|
||||
- **WHEN** 旧资产为 iot_card 且新资产为 device
|
||||
- **THEN** 系统 MUST 拒绝发货并返回“换货资产类型必须一致”
|
||||
|
||||
---
|
||||
|
||||
### Requirement: H5 确认完成
|
||||
|
||||
系统 SHALL 提供 `POST /api/admin/exchanges/:id/complete`(`Auth=true`)。
|
||||
|
||||
系统 MUST 校验当前状态为 `3`。当 `migrate_data=true` 时,系统 MUST 执行全量迁移事务(见 `exchange-data-migration` 能力)。
|
||||
|
||||
成功后 SHALL:
|
||||
- `migration_completed=true`(若执行迁移)
|
||||
- 换货单状态更新为 `4`
|
||||
|
||||
错误响应 MUST 至少包含:非法状态、迁移失败、换货单不存在或无权限。
|
||||
|
||||
#### Scenario: 需要迁移并完成
|
||||
- **WHEN** 状态为 `3` 且 `migrate_data=true`
|
||||
- **THEN** 系统 MUST 在事务成功后将状态变为 `4` 并记录迁移结果
|
||||
|
||||
---
|
||||
|
||||
### Requirement: H6 取消换货
|
||||
|
||||
系统 SHALL 提供 `POST /api/admin/exchanges/:id/cancel`(`Auth=true`)。
|
||||
|
||||
系统 MUST 仅允许在 `status IN (1,2)` 时取消,成功后状态更新为 `5`。
|
||||
|
||||
系统 MUST 禁止已发货单取消(`status=3`)。
|
||||
|
||||
#### Scenario: 已发货单取消失败
|
||||
- **WHEN** 换货单状态为 `3` 发起取消
|
||||
- **THEN** 系统 MUST 返回状态非法错误
|
||||
|
||||
---
|
||||
|
||||
### Requirement: H7 旧资产转新
|
||||
|
||||
系统 SHALL 提供 `POST /api/admin/exchanges/:id/renew`(`Auth=true`)。
|
||||
|
||||
系统 MUST 校验旧资产当前 `asset_status=3`(已换货),并执行:
|
||||
- `generation + 1`
|
||||
- `asset_status -> 1`
|
||||
- 清除累计充值/首充相关状态
|
||||
- 清除个人客户绑定
|
||||
- 创建新空钱包
|
||||
|
||||
系统 MUST 保留历史数据,不执行历史删除。
|
||||
|
||||
错误响应 MUST 至少包含:资产状态不满足转新条件、换货单不存在或无权限。
|
||||
|
||||
#### Scenario: 旧资产未处于已换货状态
|
||||
- **WHEN** 旧资产 `asset_status != 3` 发起转新
|
||||
- **THEN** 系统 MUST 拒绝并返回“资产当前状态不允许转新”
|
||||
@@ -0,0 +1,35 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: G1 查询进行中换货通知
|
||||
|
||||
系统 SHALL 提供 `GET /api/c/v1/exchange/pending?identifier=xxx`(需个人客户认证 `Auth=true`)。
|
||||
|
||||
系统 MUST 根据资产标识查询当前客户可见的进行中换货单,仅返回 `status IN (1,2,3)` 的记录。
|
||||
|
||||
响应 SHALL 至少包含:换货单 ID、单号、状态、换货原因、创建时间。
|
||||
|
||||
错误响应 MUST 至少包含:参数错误、资产不存在或无权限。
|
||||
|
||||
#### Scenario: 命中进行中换货单
|
||||
- **WHEN** 客户按资产标识查询且存在状态为 2 的换货单
|
||||
- **THEN** 系统返回该换货单并标识当前状态为待发货
|
||||
|
||||
---
|
||||
|
||||
### Requirement: G2 填写收货信息
|
||||
|
||||
系统 SHALL 提供 `POST /api/c/v1/exchange/:id/shipping-info`(需个人客户认证 `Auth=true`)。
|
||||
|
||||
请求体 MUST 包含:`recipient_name`、`recipient_phone`、`recipient_address`。
|
||||
|
||||
系统 MUST 校验:
|
||||
- 换货单存在且当前客户有权限
|
||||
- 当前状态必须为 `1`
|
||||
|
||||
成功后 SHALL 写入收货信息并将状态更新为 `2`。
|
||||
|
||||
错误响应 MUST 至少包含:参数错误、状态非法、换货单不存在或无权限。
|
||||
|
||||
#### Scenario: 非待填写状态禁止更新收货信息
|
||||
- **WHEN** 换货单当前状态为 `2` 或 `3`
|
||||
- **THEN** 系统 MUST 拒绝填写并返回状态非法错误
|
||||
@@ -0,0 +1,60 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 全量迁移事务边界
|
||||
|
||||
系统 MUST 在 H5 确认完成且 `migrate_data=true` 时,使用**单一数据库事务**执行全量迁移。
|
||||
|
||||
该事务 SHALL 覆盖资产钱包、套餐、标签、客户绑定及资产状态更新等所有步骤;任一步骤失败 MUST 回滚。
|
||||
|
||||
#### Scenario: 迁移中途失败回滚
|
||||
- **WHEN** 迁移第 N 步发生数据库错误
|
||||
- **THEN** 系统 MUST 回滚整个事务,换货单状态保持未完成
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 11 张表迁移规则
|
||||
|
||||
系统 SHALL 按以下规则处理 11 张表:
|
||||
|
||||
1. `tb_asset_wallet`:将旧资产钱包余额转移到新资产钱包。
|
||||
2. `tb_asset_wallet_transaction`:生成一条迁移流水记录(明确来源钱包、目标钱包、金额、业务类型)。
|
||||
3. `tb_asset_recharge_record`:历史充值记录保留,不做更新。
|
||||
4. `tb_package_usage`:将生效套餐关联到新资产(更新 `iot_card_id` 或 `device_id`)。
|
||||
5. `tb_package_usage_daily_record`:随 `tb_package_usage` 关系迁移(保持套餐日明细连续性)。
|
||||
6. `tb_order`:历史订单保留,不做更新。
|
||||
7. `tb_commission`:历史分佣记录保留,不做更新。
|
||||
8. `tb_data_usage_record`:历史流量记录保留,不做更新。
|
||||
9. `tb_resource_tag`:复制旧资产标签到新资产。
|
||||
10. `tb_personal_customer_device`:将绑定记录中的 `virtual_no` 更新为新资产虚拟号。
|
||||
11. `tb_iot_card`/`tb_device`:迁移累计充值与首充状态到新资产,并将旧资产 `asset_status -> 3`。
|
||||
|
||||
#### Scenario: 钱包余额转移并记录流水
|
||||
- **WHEN** 旧资产钱包余额为 5000 分
|
||||
- **THEN** 新资产钱包余额增加 5000 分,旧钱包余额按迁移策略清零,并写入迁移流水
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备换设备特殊规则
|
||||
|
||||
设备换设备流程 MUST NOT 迁移 `DeviceSimBinding`。
|
||||
|
||||
系统 SHALL 视新设备为新硬件交付,新设备卡绑定由其自身体系决定,旧设备绑定关系保留历史。
|
||||
|
||||
#### Scenario: 设备换设备不复制绑定卡
|
||||
- **WHEN** 执行设备换设备全量迁移
|
||||
- **THEN** 系统 MUST 不创建或复制任何 `DeviceSimBinding` 记录到新设备
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 转新规则
|
||||
|
||||
系统 SHALL 在 H7 转新时执行代际隔离策略:
|
||||
- 资产 `generation + 1`
|
||||
- 创建新空钱包(新 `wallet_id`)
|
||||
- 清除累计充值状态与首充触发状态
|
||||
- 清除 `PersonalCustomerDevice` 绑定
|
||||
- 不删除历史业务数据
|
||||
|
||||
#### Scenario: 转新后历史数据保留
|
||||
- **WHEN** 资产转新完成
|
||||
- **THEN** 历史订单、充值、分佣、流量数据 MUST 仍可在旧代际查询链路中追溯
|
||||
@@ -0,0 +1,69 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ExchangeOrder 换货单模型定义
|
||||
|
||||
系统 SHALL 定义 `ExchangeOrder` 模型并映射到 `tb_exchange_order`,用于承载客户端换货完整生命周期。
|
||||
|
||||
模型字段 MUST 至少包含:
|
||||
- 基础:`id`、`created_at`、`updated_at`、`deleted_at`、`creator`、`updater`
|
||||
- 单号:`exchange_no`
|
||||
- 旧资产:`old_asset_type`、`old_asset_id`、`old_asset_identifier`
|
||||
- 新资产:`new_asset_type`、`new_asset_id`、`new_asset_identifier`
|
||||
- 收货:`recipient_name`、`recipient_phone`、`recipient_address`
|
||||
- 物流:`express_company`、`express_no`
|
||||
- 迁移:`migrate_data`、`migration_completed`、`migration_balance`
|
||||
- 业务:`exchange_reason`、`remark`、`status`
|
||||
- 多租户:`shop_id`
|
||||
|
||||
`ExchangeOrder` SHALL 嵌入 `BaseModel` 并实现 `TableName() string`,返回 `tb_exchange_order`。
|
||||
|
||||
#### Scenario: 创建换货单模型实例
|
||||
- **WHEN** 系统创建新的换货单记录
|
||||
- **THEN** 记录 MUST 同时包含旧资产快照、收货信息占位、迁移状态字段和多租户字段
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 换货状态常量定义
|
||||
|
||||
系统 MUST 使用 int 常量定义换货状态:
|
||||
- `1` 待填写信息
|
||||
- `2` 待发货
|
||||
- `3` 已发货待确认
|
||||
- `4` 已完成
|
||||
- `5` 已取消
|
||||
|
||||
#### Scenario: 状态常量一致性
|
||||
- **WHEN** Service、Store、Handler 读取或更新换货状态
|
||||
- **THEN** 各层 MUST 使用统一常量值,禁止硬编码散落魔法数字
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 换货状态机流转规则
|
||||
|
||||
系统 SHALL 执行以下状态机:
|
||||
- 创建换货单后:`1`
|
||||
- 客户填写收货信息后:`1 -> 2`
|
||||
- 后台发货后:`2 -> 3`
|
||||
- 后台确认完成后:`3 -> 4`
|
||||
- 取消:仅允许 `1/2 -> 5`
|
||||
|
||||
系统 MUST 禁止非法流转(如 `3 -> 5`、`4 -> 2`)。
|
||||
|
||||
#### Scenario: 已发货不可取消
|
||||
- **WHEN** 换货单状态为 `3` 且请求取消
|
||||
- **THEN** 系统 MUST 拒绝并返回状态流转非法错误
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 换货单号生成规则
|
||||
|
||||
系统 MUST 为每个换货单生成全局可追踪单号,格式为:`EXC + 时间戳片段 + 随机数片段`。
|
||||
|
||||
生成规则 SHALL 满足:
|
||||
- 前缀固定为 `EXC`
|
||||
- 包含日期/时间信息用于人工排查
|
||||
- 包含随机片段降低并发冲突概率
|
||||
|
||||
#### Scenario: 生成换货单号
|
||||
- **WHEN** 后台发起换货并创建新单
|
||||
- **THEN** 系统 MUST 生成形如 `EXC20260319XXXXXX` 的单号并写入 `exchange_no`
|
||||
@@ -0,0 +1,23 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: IoT 卡换货状态语义扩展
|
||||
|
||||
系统 SHALL 将 `asset_status=3` 定义为“已换货”,用于标记已被换出、不可继续作为当前代际在售资产的 IoT 卡。
|
||||
|
||||
#### Scenario: 换货完成后旧卡标记为已换货
|
||||
- **WHEN** H5 确认完成且旧资产为 IoT 卡
|
||||
- **THEN** 系统 MUST 将旧卡 `asset_status` 更新为 `3`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: IoT 卡转新重置规则
|
||||
|
||||
系统 SHALL 在 H7 转新时对 IoT 卡执行以下重置:
|
||||
- `generation = generation + 1`
|
||||
- `asset_status = 1`(在库)
|
||||
- 清空累计充值与首充触发相关状态(含 `AccumulatedRecharge`、`FirstCommissionPaid`、系列首充/累计字段)
|
||||
- 清除个人客户绑定关系
|
||||
|
||||
#### Scenario: 转新后进入新代际
|
||||
- **WHEN** 对旧卡执行转新
|
||||
- **THEN** 系统 MUST 使该卡进入新代际并以在库状态重新销售
|
||||
@@ -0,0 +1,21 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 换货迁移时更新个人客户资产绑定
|
||||
|
||||
系统 SHALL 在 H5 全量迁移成功后,更新 `PersonalCustomerDevice` 的资产标识绑定关系:
|
||||
- 若旧资产存在客户绑定,绑定中的 `virtual_no` MUST 更新为新资产 `virtual_no`
|
||||
- 更新后客户对资产访问连续,不需重新登录即可看到新资产
|
||||
|
||||
#### Scenario: 迁移后客户绑定跟随新资产
|
||||
- **WHEN** 旧资产存在个人客户绑定且执行了 `migrate_data=true`
|
||||
- **THEN** 系统 MUST 将绑定记录的 `virtual_no` 更新为新资产虚拟号
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 转新时清除个人客户绑定
|
||||
|
||||
系统 SHALL 在 H7 转新时清除该资产在 `PersonalCustomerDevice` 中的绑定关系,避免旧客户继续访问新代际资产。
|
||||
|
||||
#### Scenario: 转新后旧客户需重新绑定
|
||||
- **WHEN** 资产转新完成
|
||||
- **THEN** 系统 MUST 删除或失效对应客户绑定,使旧客户再次访问时触发重新绑定流程
|
||||
37
openspec/changes/client-exchange-system/tasks.md
Normal file
37
openspec/changes/client-exchange-system/tasks.md
Normal file
@@ -0,0 +1,37 @@
|
||||
- [x] 1.1 定义 ExchangeOrder 模型(含 BaseModel、旧/新资产字段、收货字段、物流字段、迁移字段、shop_id)
|
||||
- [x] 1.2 新增换货状态常量(1待填写、2待发货、3已发货待确认、4已完成、5已取消)
|
||||
- [x] 1.3 实现换货单号生成函数(EXC + 日期时间 + 随机数)
|
||||
- [x] 1.4 新增后台/客户端换货相关 DTO(请求参数、响应结构、错误字段)
|
||||
|
||||
- [x] 2.1 创建数据库迁移:新增 tb_exchange_order 表
|
||||
- [x] 2.2 创建数据库迁移:将 tb_card_replacement_record 改名为 tb_card_replacement_record_legacy
|
||||
|
||||
- [x] 3.1 实现 ExchangeOrderStore:创建换货单、按ID查询、按条件分页查询
|
||||
- [x] 3.2 实现 ExchangeOrderStore:状态更新(含前置状态校验)
|
||||
- [x] 3.3 实现 ExchangeOrderStore:按旧资产查询进行中换货单
|
||||
|
||||
- [x] 4.1 实现换货 Service:H1 创建换货单与重复进行中校验
|
||||
- [x] 4.2 实现换货 Service:状态流转校验(1->2->3->4、1/2->5)
|
||||
- [x] 4.3 实现换货 Service:H4 发货同类型资产校验与新资产在库校验
|
||||
- [x] 4.4 实现换货 Service:H5 确认完成与可选全量迁移入口
|
||||
- [x] 4.5 实现换货 Service:全量迁移事务(11张表规则)
|
||||
- [x] 4.6 实现换货 Service:H7 转新逻辑(generation+1、状态重置、清除绑定、新钱包)
|
||||
|
||||
- [x] 5.1 新增后台 Exchange Handler(H1~H7)
|
||||
- [x] 5.2 在 admin 路由注册 H1~H7(使用 Register() + RouteSpec 完整元数据)
|
||||
|
||||
- [x] 6.1 新增客户端 Exchange Handler(G1~G2)
|
||||
- [x] 6.2 在客户端路由注册 G1~G2(使用 Register() + RouteSpec 完整元数据)
|
||||
|
||||
- [x] 7.1 清理旧模型引用:移除/停用 card_replacement.go 在业务流程中的使用
|
||||
- [x] 7.2 修改 iot_card_store.go 的 is_replaced 过滤逻辑,改为查询 ExchangeOrder
|
||||
|
||||
- [x] 8.1 更新 bootstrap/types.go:新增后台与客户端换货 Handler 字段
|
||||
- [x] 8.2 更新 bootstrap/handlers.go:实例化换货相关 Handler
|
||||
- [x] 8.3 更新 cmd/api/docs.go:注册换货 Handler 到文档生成器
|
||||
- [x] 8.4 更新 cmd/gendocs/main.go:注册换货 Handler 到文档生成器
|
||||
|
||||
- [x] 9.1 执行 go build 验证编译通过
|
||||
- [x] 9.2 执行 lsp_diagnostics 检查改动文件诊断信息
|
||||
- [x] 9.3 使用数据库验证流程核对 tb_exchange_order 与 legacy 表结构
|
||||
- [x] 9.4 在 docs/client-exchange-system/ 补充功能总结文档
|
||||
@@ -64,7 +64,8 @@ const (
|
||||
TaskTypePackageDataReset = "package:data:reset" // 套餐流量重置
|
||||
|
||||
// 订单超时任务类型
|
||||
TaskTypeOrderExpire = "order:expire" // 订单超时自动取消
|
||||
TaskTypeOrderExpire = "order:expire" // 订单超时自动取消
|
||||
TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" // 充值后自动购包
|
||||
|
||||
// 定时任务类型(由 Asynq Scheduler 调度)
|
||||
TaskTypeAlertCheck = "alert:check" // 告警检查
|
||||
@@ -205,8 +206,30 @@ const (
|
||||
AuthorizerTypeAgent = UserTypeAgent // 代理账号授权(3)
|
||||
)
|
||||
|
||||
// 自动代购状态常量(强充二阶段异步购买)
|
||||
const (
|
||||
AutoPurchaseStatusPending = "pending" // 待处理
|
||||
AutoPurchaseStatusSuccess = "success" // 成功
|
||||
AutoPurchaseStatusFailed = "failed" // 失败
|
||||
)
|
||||
|
||||
// 设备保护期相关时长常量
|
||||
const (
|
||||
DeviceProtectPeriodDuration = 1 * time.Hour // 设备停/复机保护期时长(1小时)
|
||||
DeviceRefreshCooldownDuration = 30 * time.Second // 设备网关刷新冷却时长(30秒)
|
||||
)
|
||||
|
||||
// 换货单状态常量
|
||||
const (
|
||||
ExchangeStatusPendingInfo = 1 // 待填写信息(等待客户端填写收货地址)
|
||||
ExchangeStatusPendingShip = 2 // 待发货(客户端已填写,等待后台发货)
|
||||
ExchangeStatusShipped = 3 // 已发货待确认(后台已发货,等待确认完成)
|
||||
ExchangeStatusCompleted = 4 // 已完成
|
||||
ExchangeStatusCancelled = 5 // 已取消
|
||||
)
|
||||
|
||||
// 换货资产类型常量
|
||||
const (
|
||||
ExchangeAssetTypeIotCard = "iot_card" // 物联网卡
|
||||
ExchangeAssetTypeDevice = "device" // 设备
|
||||
)
|
||||
|
||||
@@ -329,6 +329,24 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string {
|
||||
return fmt.Sprintf("order:create:lock:%s:%d", carrierType, carrierID)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 客户端购买相关 Redis Key
|
||||
// ========================================
|
||||
|
||||
// RedisClientPurchaseIdempotencyKey 生成客户端套餐购买幂等性检测的 Redis 键
|
||||
// 用途:防止同一个人客户对同一资产重复提交购买请求(SETNX 快速拒绝)
|
||||
// 过期时间:3 分钟
|
||||
func RedisClientPurchaseIdempotencyKey(businessKey string) string {
|
||||
return fmt.Sprintf("client:purchase:idempotency:%s", businessKey)
|
||||
}
|
||||
|
||||
// RedisClientPurchaseLockKey 生成客户端套餐购买分布式锁的 Redis 键
|
||||
// 用途:防止同一资产的购买请求并发执行
|
||||
// 过期时间:10 秒
|
||||
func RedisClientPurchaseLockKey(assetType string, assetID uint) string {
|
||||
return fmt.Sprintf("client:purchase:lock:%s:%d", assetType, assetID)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 设备保护期相关 Redis Key
|
||||
// ========================================
|
||||
|
||||
@@ -149,6 +149,17 @@ const (
|
||||
CodePhoneAlreadyBound = 1184 // 手机号已被其他客户绑定
|
||||
CodeAlreadyBoundPhone = 1185 // 当前客户已绑定手机号,不可重复绑定
|
||||
CodeOldPhoneMismatch = 1186 // 旧手机号与当前绑定不匹配
|
||||
CodeNeedRealname = 1187 // 该套餐需实名认证后购买
|
||||
CodeOpenIDNotFound = 1188 // 未找到微信授权信息,请先完成授权
|
||||
|
||||
// 换货相关错误 (1200-1209)
|
||||
CodeExchangeOrderNotFound = 1200 // 换货单不存在
|
||||
CodeExchangeInProgress = 1201 // 存在进行中的换货单
|
||||
CodeExchangeStatusInvalid = 1202 // 换货单状态不允许此操作
|
||||
CodeExchangeAssetTypeMismatch = 1203 // 换货资产类型必须一致
|
||||
CodeExchangeNewAssetNotInStock = 1204 // 新资产非在库状态
|
||||
CodeExchangeAssetNotExchanged = 1205 // 资产未处于已换货状态,不允许转新
|
||||
CodeExchangeMigrationFailed = 1206 // 数据迁移失败
|
||||
|
||||
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码
|
||||
CodeInternalError = 2001 // 内部服务器错误
|
||||
@@ -274,6 +285,15 @@ var allErrorCodes = []int{
|
||||
CodePhoneAlreadyBound,
|
||||
CodeAlreadyBoundPhone,
|
||||
CodeOldPhoneMismatch,
|
||||
CodeNeedRealname,
|
||||
CodeOpenIDNotFound,
|
||||
CodeExchangeOrderNotFound,
|
||||
CodeExchangeInProgress,
|
||||
CodeExchangeStatusInvalid,
|
||||
CodeExchangeAssetTypeMismatch,
|
||||
CodeExchangeNewAssetNotInStock,
|
||||
CodeExchangeAssetNotExchanged,
|
||||
CodeExchangeMigrationFailed,
|
||||
CodeInternalError,
|
||||
CodeDatabaseError,
|
||||
CodeRedisError,
|
||||
@@ -396,6 +416,15 @@ var errorMessages = map[int]string{
|
||||
CodePhoneAlreadyBound: "手机号已被其他客户绑定",
|
||||
CodeAlreadyBoundPhone: "当前客户已绑定手机号,不可重复绑定",
|
||||
CodeOldPhoneMismatch: "旧手机号与当前绑定不匹配",
|
||||
CodeNeedRealname: "该套餐需实名认证后购买",
|
||||
CodeOpenIDNotFound: "未找到微信授权信息,请先完成授权",
|
||||
CodeExchangeOrderNotFound: "换货单不存在或无权限",
|
||||
CodeExchangeInProgress: "该资产存在进行中的换货单",
|
||||
CodeExchangeStatusInvalid: "换货单当前状态不允许此操作",
|
||||
CodeExchangeAssetTypeMismatch: "换货资产类型必须一致(卡换卡/设备换设备)",
|
||||
CodeExchangeNewAssetNotInStock: "新资产非在库状态,不可用于换货",
|
||||
CodeExchangeAssetNotExchanged: "资产当前状态不允许转新",
|
||||
CodeExchangeMigrationFailed: "换货数据迁移失败",
|
||||
CodeInvalidCredentials: "用户名或密码错误",
|
||||
CodeAccountLocked: "账号已锁定",
|
||||
CodePasswordExpired: "密码已过期",
|
||||
|
||||
@@ -16,6 +16,12 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
||||
Role: admin.NewRoleHandler(nil, nil),
|
||||
Permission: admin.NewPermissionHandler(nil),
|
||||
PersonalCustomer: app.NewPersonalCustomerHandler(nil, nil),
|
||||
ClientAsset: app.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil),
|
||||
ClientWallet: app.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil),
|
||||
ClientOrder: app.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil),
|
||||
ClientExchange: app.NewClientExchangeHandler(nil),
|
||||
ClientRealname: app.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil),
|
||||
ClientDevice: app.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil),
|
||||
Shop: admin.NewShopHandler(nil),
|
||||
ShopRole: admin.NewShopRoleHandler(nil),
|
||||
ShopCommission: admin.NewShopCommissionHandler(nil),
|
||||
@@ -40,6 +46,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
|
||||
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil),
|
||||
AdminOrder: admin.NewOrderHandler(nil, nil),
|
||||
AdminExchange: admin.NewExchangeHandler(nil, nil),
|
||||
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil),
|
||||
PollingConfig: admin.NewPollingConfigHandler(nil),
|
||||
PollingConcurrency: admin.NewPollingConcurrencyHandler(nil),
|
||||
|
||||
Reference in New Issue
Block a user