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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user