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

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

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

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

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

View File

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

View File

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

View File

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