实现换货资产快照与新旧独立搜索

This commit is contained in:
2026-07-22 16:37:08 +09:00
parent 785907ce85
commit 55bdc3a8d0
14 changed files with 1060 additions and 128 deletions

View File

@@ -225,6 +225,7 @@ default:
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能完整的权限检查功能支持路由级别的细粒度权限控制支持平台过滤web/h5/all和超级管理员自动跳过详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md)
- **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md)
- **UR#60 店铺联系电话精确查询**:店铺列表支持 11 位 ASCII 联系电话精确筛选,统一执行查询参数校验并返回一致的默认分页元数据,企业账号禁止访问五个核心店铺管理入口;详见 [功能总结](docs/ur60-shop-phone-search/功能总结.md)。
- **UR#45 换货资产快照与新旧资产独立搜索**:新换货单将卡快照统一为完整 ICCID、设备按虚拟号/IMEI/SN 固化稳定标识;列表使用 `old_asset_keyword``new_asset_keyword` 独立搜索并按 AND 组合;详见 [功能总结](docs/ur45-exchange-asset-search/功能总结.md)。
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制**登录响应包含菜单树和按钮权限**menus/buttons前端无需二次处理直接渲染侧边栏和控制按钮显示详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户

View File

@@ -3323,7 +3323,7 @@ components:
nullable: true
type: boolean
new_identifier:
description: 新资产标识direct 流程必填(ICCID/虚拟号/IMEI/SN)
description: 新资产输入标识direct 流程必填;卡支持 ICCID、接入号、虚拟号设备支持虚拟号、IMEISN
maxLength: 100
minLength: 1
type: string
@@ -3331,7 +3331,7 @@ components:
description: 旧资产类型 (iot_card:物联网卡, device:设备)
type: string
old_identifier:
description: 旧资产标识符(ICCID/虚拟号/IMEI/SN)
description: 旧资产输入标识,卡支持 ICCID、接入号、虚拟号设备支持虚拟号IMEISN;响应快照使用权威标识
maxLength: 100
minLength: 1
type: string
@@ -5175,7 +5175,7 @@ components:
nullable: true
type: integer
new_asset_identifier:
description: 新资产标识符
description: 新资产权威快照,卡为完整 ICCID设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值
type: string
new_asset_type:
description: 新资产类型 (iot_card:物联网卡, device:设备)
@@ -5185,7 +5185,7 @@ components:
minimum: 0
type: integer
old_asset_identifier:
description: 旧资产标识符
description: 旧资产权威快照,卡为完整 ICCID设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值
type: string
old_asset_type:
description: 旧资产类型 (iot_card:物联网卡, device:设备)
@@ -5247,7 +5247,7 @@ components:
description: 是否执行全量迁移 (true:执行, false:不执行)
type: boolean
new_identifier:
description: 新资产标识符(ICCID/虚拟号/IMEI/SN)
description: 新资产输入标识,卡支持 ICCID、接入号、虚拟号设备支持虚拟号IMEISN;保存快照使用权威标识
maxLength: 100
minLength: 1
type: string
@@ -16195,6 +16195,7 @@ paths:
schema:
description: 页码
minimum: 1
nullable: true
type: integer
- description: 每页数量
in: query
@@ -16203,6 +16204,7 @@ paths:
description: 每页数量
maximum: 100
minimum: 1
nullable: true
type: integer
- description: 换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)
in: query
@@ -16222,11 +16224,18 @@ paths:
- shipping
- direct
type: string
- description: 资产标识符搜索(旧资产/新资产标识符模糊匹配)
- description: 旧资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN与新资产关键词按 AND 组合
in: query
name: identifier
name: old_asset_keyword
schema:
description: 资产标识符搜索(旧资产/新资产标识符模糊匹配)
description: 旧资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN与新资产关键词按 AND 组合
maxLength: 100
type: string
- description: 新资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN与旧资产关键词按 AND 组合
in: query
name: new_asset_keyword
schema:
description: 新资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN与旧资产关键词按 AND 组合
maxLength: 100
type: string
- description: 创建时间起始

View File

@@ -0,0 +1,72 @@
# UR#45 换货资产快照与新旧资产独立搜索功能总结
## 本次交付范围
本次完成两张可独立发布的 Ticket
- Ticket 01统一物流换货创建、直接换货创建和物流发货三个写入入口的资产解析与权威快照。
- Ticket 02`GET /api/admin/exchanges` 列表读取收口到独立 Query提供新旧资产独立搜索、完整校验、权限、分页、错误转换、OpenAPI 和真实 PostgreSQL 验证。
未迁移换货详情、状态机、完成、取消、资料迁移、旧资产转新等旧用例;未回填历史快照;未实现 UR#86 资产前后代关系或 UR#98 店铺继承。
## 权威快照规则
- IoT 卡可通过 ICCID、接入号或虚拟号定位但新写入的 `old_asset_identifier``new_asset_identifier` 始终保存数据库中的完整 ICCID。
- 设备可通过虚拟号、IMEI 或 SN 定位,快照按“虚拟号 → IMEI → SN”选择首个非空稳定标识。
- 物流创建的旧资产、直接换货的新旧资产、物流发货的新资产共享同一规范化能力。
- 标识解析继续应用既有店铺数据范围、资产类型、状态和并发校验。不存在、类型不匹配或数据库故障使用统一错误体系,不返回底层错误。
- 历史换货单保持原快照,不做自动清洗或回填;列表搜索按资产类型和资产主键命中,因此仍可通过资产当前支持的任一标识找到历史记录。
## 列表搜索契约
`GET /api/admin/exchanges` 使用以下可选参数:
- `old_asset_keyword`:最长 100 字符,只过滤旧资产一侧。
- `new_asset_keyword`:最长 100 字符,只过滤新资产一侧。
- 两者同时提交时按 AND 组合,并继续与 `status``flow_type``created_at_start``created_at_end` 和分页条件按 AND 组合。
- 空值不增加对应过滤;无候选或无换货单命中时返回成功空分页。
- 卡候选对 ICCID、接入号和虚拟号做包含匹配设备候选对虚拟号、IMEI 和 SN 做包含匹配GORM 默认排除软删除资产和换货单。
- 旧通用 `identifier` 已从新 DTO 与 OpenAPI 契约移除。
列表采用 `Handler → Query → GORM/DTO` 通道。候选资产通过数据库子查询参与最终换货单条件,不逐条读取资产;`total``items` 复用同一过滤链,按 `created_at DESC` 排序。最终换货单查询继续应用既有店铺范围,候选解析不会扩大平台、超级管理员或代理的可见数据。
## 校验与错误边界
Handler 对完整请求 DTO 执行校验。显式 `page=0``page_size=0`、超上限分页、非法状态、非法流程类型、超长关键词和非法时间格式统一返回 HTTP 400
```json
{
"code": 1001,
"msg": "参数验证失败",
"data": null,
"timestamp": "RFC3339 时间"
}
```
候选或换货单查询失败返回脱敏 HTTP 500、`code=2002``msg=数据库错误`,不得将故障降级为空结果,也不向客户端暴露 SQL、候选数量或底层错误。
## 验证证据
- 真实 PostgreSQL 写用例测试覆盖卡 ICCID、接入号、虚拟号和设备虚拟号、IMEI、SN 输入,并验证物流创建、直接创建和物流发货的持久化快照。
- 设备无虚拟号时的 IMEI、SN 回退规则由规范化单元测试覆盖;历史非规范快照保持原值。
- Query 集成测试覆盖六类标识、新旧独立、双关键词 AND、状态和时间组合、空结果、历史快照、软删除资产、店铺范围、分页排序与数据库故障。
- 代表性数据集在真实 PostgreSQL 中创建 120 条匹配换货单;连续执行 100 次第 2 页、每页 20 条查询,总数与排序保持一致,每次固定执行 2 条 SQL计数 + 分页),并断言 P95 `<200ms`、P99 `<500ms`;测试同时执行 `EXPLAIN` 记录等价查询计划,证明没有逐行资产反查。
- HTTP 测试验证完整 DTO 校验、独立关键词传递、统一分页外层和脱敏数据库错误。
- OpenAPI 使用 `go run ./cmd/gendocs` 重新生成并核验新旧关键词、长度限制、AND 说明、权威快照响应语义以及旧 `identifier` 不再属于列表参数。
## 前端人工验收清单
- 将原单一资产输入框拆为“旧资产”和“新资产”两个输入框。
- 空值不提交;两个非空值同时提交并按 AND 展示结果。
- 表格分别展示旧资产类型/标识和新资产类型/标识,不混列。
- 前端不解析接入号、虚拟号或 ICCID不在当前页本地过滤。
- 清空、分页、加载、空态和失败反馈沿用现有交互;失败时不得把旧结果伪装为新查询结果。
当前仓库没有可实施该页面的前端工程,因此前端部分以接口契约和本清单交付,等待同维护窗口的前端仓库实施与浏览器人工验收。
## 发布与回滚
- 发布时后端与前端在同一维护窗口切换:前端停止提交列表 `identifier`,改为提交 `old_asset_keyword` / `new_asset_keyword`
- 本次无数据库迁移,不修改历史数据;后端回滚只需回退应用与 OpenAPI 产物。
- 若仅回滚前端,旧 `identifier` 不会产生筛选效果,因此不支持前后端跨版本长期混用;应整体回滚到上一版本。
- 发布后重点观察列表 HTTP 5xx、查询耗时和空结果比例异常时按应用版本整体回滚不执行数据修复。

View File

@@ -7,6 +7,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi"
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
@@ -109,7 +110,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),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, deps.WechatPayment, svc.WechatConfig, paymentStore, deps.Logger),
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
@@ -137,12 +138,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
h.SetAssetService(svc.Asset)
return h
}(),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate),
Refund: admin.NewRefundHandler(svc.Refund),
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate),
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),
}
}

View File

@@ -1,25 +1,38 @@
package admin
import (
"context"
"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/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
// ExchangeLister 定义换货列表读取用例。
type ExchangeLister interface {
List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error)
}
// ExchangeHandler 处理后台换货管理接口。
type ExchangeHandler struct {
service *exchangeService.Service
listQuery ExchangeLister
validator *validator.Validate
}
func NewExchangeHandler(service *exchangeService.Service, validator *validator.Validate) *ExchangeHandler {
return &ExchangeHandler{service: service, validator: validator}
// NewExchangeHandler 创建后台换货管理 Handler
func NewExchangeHandler(service *exchangeService.Service, listQuery ExchangeLister, validator *validator.Validate) *ExchangeHandler {
return &ExchangeHandler{service: service, listQuery: listQuery, validator: validator}
}
// Create 创建换货单。
// POST /api/admin/exchanges
func (h *ExchangeHandler) Create(c *fiber.Ctx) error {
var req dto.CreateExchangeRequest
if err := c.BodyParser(&req); err != nil {
@@ -36,22 +49,37 @@ func (h *ExchangeHandler) Create(c *fiber.Ctx) error {
return response.Success(c, data)
}
// List 查询换货单列表。
// GET /api/admin/exchanges
func (h *ExchangeHandler) List(c *fiber.Ctx) error {
var req dto.ExchangeListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
h.logListValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&req); err != nil {
h.logListValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
data, err := h.service.List(c.UserContext(), &req)
data, err := h.listQuery.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, data)
}
func (h *ExchangeHandler) logListValidationFailure(c *fiber.Ctx, err error) {
logger.GetAppLogger().Warn("换货列表参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.String("query", c.Context().QueryArgs().String()),
zap.Error(err),
)
}
// Get 查询换货单详情。
// GET /api/admin/exchanges/:id
func (h *ExchangeHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
@@ -65,6 +93,8 @@ func (h *ExchangeHandler) Get(c *fiber.Ctx) error {
return response.Success(c, data)
}
// Ship 执行物流换货发货。
// POST /api/admin/exchanges/:id/ship
func (h *ExchangeHandler) Ship(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
@@ -86,6 +116,8 @@ func (h *ExchangeHandler) Ship(c *fiber.Ctx) error {
return response.Success(c, data)
}
// Complete 确认换货完成。
// POST /api/admin/exchanges/:id/complete
func (h *ExchangeHandler) Complete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
@@ -98,6 +130,8 @@ func (h *ExchangeHandler) Complete(c *fiber.Ctx) error {
return response.Success(c, nil)
}
// Cancel 取消换货单。
// POST /api/admin/exchanges/:id/cancel
func (h *ExchangeHandler) Cancel(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
@@ -118,6 +152,8 @@ func (h *ExchangeHandler) Cancel(c *fiber.Ctx) error {
return response.Success(c, nil)
}
// Renew 将已换出的旧资产转为新资产。
// POST /api/admin/exchanges/:id/renew
func (h *ExchangeHandler) Renew(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {

View File

@@ -0,0 +1,252 @@
package admin
import (
"context"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"time"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
"github.com/break/junhong_cmp_fiber/internal/testutil"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/bytedance/sonic"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"gorm.io/gorm"
)
// TestExchangeListValidatesCompleteRequest 验证列表 Handler 对全部查询字段执行统一校验。
func TestExchangeListValidatesCompleteRequest(t *testing.T) {
testCases := []string{
"page=0", "page_size=0", "page_size=101", "status=0", "status=6", "flow_type=invalid",
"old_asset_keyword=" + url.QueryEscape(strings.Repeat("旧", 101)),
"new_asset_keyword=" + url.QueryEscape(strings.Repeat("新", 101)),
"created_at_start=invalid",
}
for _, query := range testCases {
t.Run(query, func(t *testing.T) {
app, _ := newExchangeListTestApp(&exchangeListStub{})
status, body := exchangeListRequest(t, app, "/api/admin/exchanges?"+query)
if status != http.StatusBadRequest {
t.Fatalf("期望 HTTP 400实际 %d响应%s", status, body)
}
assertExchangeErrorResponse(t, body, errors.CodeInvalidParam, "参数验证失败")
})
}
}
// TestExchangeListPassesIndependentKeywordsAndReturnsUnifiedResponse 验证新旧关键词独立传入读取用例。
func TestExchangeListPassesIndependentKeywordsAndReturnsUnifiedResponse(t *testing.T) {
stub := &exchangeListStub{response: &dto.ExchangeListResponse{List: []*dto.ExchangeOrderResponse{}, Total: 0, Page: 2, PageSize: 7}}
app, _ := newExchangeListTestApp(stub)
status, body := exchangeListRequest(t, app, "/api/admin/exchanges?page=2&page_size=7&old_asset_keyword=old&new_asset_keyword=new")
if status != http.StatusOK {
t.Fatalf("期望 HTTP 200实际 %d响应%s", status, body)
}
if stub.request == nil || stub.request.OldAssetKeyword != "old" || stub.request.NewAssetKeyword != "new" {
t.Fatalf("读取用例未收到独立关键词:%+v", stub.request)
}
var response struct {
Code int `json:"code"`
Data struct {
Items []any `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
} `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析响应失败:%v", err)
}
if response.Code != errors.CodeSuccess || response.Data.Page != 2 || response.Data.Size != 7 || response.Data.Items == nil {
t.Fatalf("统一分页响应不符合契约:%s", body)
}
}
// TestExchangeListSanitizesDatabaseErrors 验证数据库故障返回脱敏统一 500。
func TestExchangeListSanitizesDatabaseErrors(t *testing.T) {
stub := &exchangeListStub{err: errors.Wrap(errors.CodeDatabaseError, context.Canceled, "查询换货单数量失败")}
app, _ := newExchangeListTestApp(stub)
status, body := exchangeListRequest(t, app, "/api/admin/exchanges?old_asset_keyword=UR45")
if status != http.StatusInternalServerError {
t.Fatalf("期望 HTTP 500实际 %d响应%s", status, body)
}
assertExchangeErrorResponse(t, body, errors.CodeDatabaseError, "数据库错误")
if strings.Contains(string(body), "context canceled") || strings.Contains(string(body), "查询换货单数量失败") {
t.Fatalf("数据库错误响应泄露内部细节:%s", body)
}
}
// TestExchangeListHTTPIntegratesSearchAndAccountScopes 验证真实 Query 的 HTTP 搜索组合和三类账号范围。
func TestExchangeListHTTPIntegratesSearchAndAccountScopes(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
shopOne, shopTwo := uint(45201), uint(45202)
oldCard := createExchangeHTTPCard(t, tx, 1, shopOne)
newCard := createExchangeHTTPCard(t, tx, 2, shopOne)
otherCard := createExchangeHTTPCard(t, tx, 3, shopTwo)
createdAt := time.Now().Add(-time.Hour).Truncate(time.Second)
visible := createExchangeHTTPOrder(t, tx, "UR45-HTTP-VISIBLE", oldCard, newCard, shopOne, createdAt)
createExchangeHTTPOrder(t, tx, "UR45-HTTP-HIDDEN", otherCard, otherCard, shopTwo, createdAt.Add(time.Minute))
handler := NewExchangeHandler(nil, exchangeQuery.NewListQuery(tx), validator.New())
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
app.Get("/api/admin/exchanges", func(c *fiber.Ctx) error {
ctx := c.UserContext()
if c.Get("X-UR45-Account") == "agent" {
ctx = context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{shopOne})
}
c.SetUserContext(ctx)
return handler.List(c)
})
start, end := createdAt.Add(-time.Minute).Format(time.RFC3339), createdAt.Add(time.Minute).Format(time.RFC3339)
query := "old_asset_keyword=" + url.QueryEscape(oldCard.MSISDN) + "&new_asset_keyword=" + url.QueryEscape(newCard.VirtualNo) + "&status=4&flow_type=direct&created_at_start=" + url.QueryEscape(start) + "&created_at_end=" + url.QueryEscape(end)
for _, account := range []string{"super_admin", "platform", "agent"} {
t.Run(account, func(t *testing.T) {
status, body := exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?"+query, account)
page := decodeExchangeHTTPPage(t, body)
if status != http.StatusOK || page.Total != 1 || len(page.Items) != 1 || uint(page.Items[0]["id"].(float64)) != visible.ID {
t.Fatalf("账号范围或组合搜索错误status=%d body=%s", status, body)
}
})
}
status, body := exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?old_asset_keyword="+url.QueryEscape(oldCard.MSISDN), "platform")
page := decodeExchangeHTTPPage(t, body)
if status != http.StatusOK || page.Total != 1 || page.Items[0]["old_asset_identifier"] != "历史旧快照" {
t.Fatalf("仅旧关键词或历史旧快照响应错误status=%d body=%s", status, body)
}
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?new_asset_keyword="+url.QueryEscape(newCard.VirtualNo), "platform")
page = decodeExchangeHTTPPage(t, body)
if status != http.StatusOK || page.Total != 1 || page.Items[0]["new_asset_identifier"] != "历史新快照" {
t.Fatalf("仅新关键词或历史新快照响应错误status=%d body=%s", status, body)
}
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges", "platform")
page = decodeExchangeHTTPPage(t, body)
if status != http.StatusOK || !exchangeHTTPPageContains(page, "UR45-HTTP-VISIBLE") || !exchangeHTTPPageContains(page, "UR45-HTTP-HIDDEN") {
t.Fatalf("空参数列表响应错误status=%d body=%s", status, body)
}
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?old_asset_keyword="+url.QueryEscape(otherCard.ICCID), "agent")
if page := decodeExchangeHTTPPage(t, body); status != http.StatusOK || page.Total != 0 || len(page.Items) != 0 {
t.Fatalf("代理关键词绕过店铺范围status=%d body=%s", status, body)
}
status, body = exchangeListRequestWithAccount(t, app, "/api/admin/exchanges?new_asset_keyword=不存在", "platform")
if page := decodeExchangeHTTPPage(t, body); status != http.StatusOK || page.Total != 0 || len(page.Items) != 0 {
t.Fatalf("无匹配应返回成功空分页status=%d body=%s", status, body)
}
}
type exchangeListStub struct {
request *dto.ExchangeListRequest
response *dto.ExchangeListResponse
err error
}
func (s *exchangeListStub) List(_ context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
s.request = req
if s.response == nil && s.err == nil {
s.response = &dto.ExchangeListResponse{List: []*dto.ExchangeOrderResponse{}, Page: constants.DefaultPage, PageSize: constants.DefaultPageSize}
}
return s.response, s.err
}
func newExchangeListTestApp(stub ExchangeLister) (*fiber.App, *ExchangeHandler) {
handler := NewExchangeHandler(nil, stub, validator.New())
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
app.Get("/api/admin/exchanges", handler.List)
return app, handler
}
func exchangeListRequest(t *testing.T, app *fiber.App, path string) (int, []byte) {
return exchangeListRequestWithAccount(t, app, path, "")
}
func exchangeListRequestWithAccount(t *testing.T, app *fiber.App, path, account string) (int, []byte) {
t.Helper()
request, err := http.NewRequest(http.MethodGet, path, nil)
if err != nil {
t.Fatalf("创建请求失败:%v", err)
}
if account != "" {
request.Header.Set("X-UR45-Account", account)
}
response, err := app.Test(request, -1)
if err != nil {
t.Fatalf("执行请求失败:%v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("读取响应失败:%v", err)
}
return response.StatusCode, body
}
type exchangeHTTPPage struct {
Items []map[string]any `json:"items"`
Total int64 `json:"total"`
}
func decodeExchangeHTTPPage(t *testing.T, body []byte) exchangeHTTPPage {
t.Helper()
var response struct {
Data exchangeHTTPPage `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析分页响应失败:%v", err)
}
return response.Data
}
func exchangeHTTPPageContains(page exchangeHTTPPage, exchangeNo string) bool {
for _, item := range page.Items {
if item["exchange_no"] == exchangeNo {
return true
}
}
return false
}
func createExchangeHTTPCard(t *testing.T, tx *gorm.DB, suffix int, shopID uint) *model.IotCard {
t.Helper()
iccid := "8986222222222222000" + strconv.Itoa(suffix)
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], MSISDN: "1360000000" + strconv.Itoa(suffix), VirtualNo: "UR45-HTTP-CARD-" + strconv.Itoa(suffix), ShopID: &shopID, AssetStatus: constants.AssetStatusInStock}
if err := tx.Create(card).Error; err != nil {
t.Fatalf("创建 HTTP 测试卡失败:%v", err)
}
return card
}
func createExchangeHTTPOrder(t *testing.T, tx *gorm.DB, exchangeNo string, oldCard, newCard *model.IotCard, shopID uint, createdAt time.Time) *model.ExchangeOrder {
t.Helper()
newID := newCard.ID
order := &model.ExchangeOrder{ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeDirect, OldAssetType: constants.ExchangeAssetTypeIotCard, OldAssetID: oldCard.ID, OldAssetIdentifier: "历史旧快照", NewAssetType: constants.ExchangeAssetTypeIotCard, NewAssetID: &newID, NewAssetIdentifier: "历史新快照", ExchangeReason: "UR45 HTTP 测试", Status: constants.ExchangeStatusCompleted, ShopID: &shopID}
order.CreatedAt, order.UpdatedAt = createdAt, createdAt
if err := tx.Create(order).Error; err != nil {
t.Fatalf("创建 HTTP 测试换货单失败:%v", err)
}
return order
}
func assertExchangeErrorResponse(t *testing.T, body []byte, expectedCode int, expectedMessage string) {
t.Helper()
var response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data any `json:"data"`
}
if err := sonic.Unmarshal(body, &response); err != nil {
t.Fatalf("解析错误响应失败:%v", err)
}
if response.Code != expectedCode || response.Msg != expectedMessage || response.Data != nil || !strings.Contains(string(body), "timestamp") {
t.Fatalf("错误响应不符合契约:%s", body)
}
}

View File

@@ -2,30 +2,34 @@ package dto
import "time"
// CreateExchangeRequest 创建换货单请求。
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)"`
OldIdentifier string `json:"old_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"旧资产输入标识,卡支持 ICCID、接入号、虚拟号设备支持虚拟号IMEISN;响应快照使用权威标识"`
FlowType string `json:"flow_type" validate:"omitempty,oneof=shipping direct" enum:"shipping,direct" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"`
NewIdentifier string `json:"new_identifier" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"新资产标识direct 流程必填(ICCID/虚拟号/IMEI/SN)"`
NewIdentifier string `json:"new_identifier" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"新资产输入标识direct 流程必填;卡支持 ICCID、接入号、虚拟号设备支持虚拟号、IMEISN"`
MigrateData *bool `json:"migrate_data" description:"是否执行全量迁移direct 流程未传按 false 处理"`
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:"备注"`
}
// ExchangeListRequest 换货单列表请求。
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:已取消)"`
FlowType string `json:"flow_type" query:"flow_type" validate:"omitempty,oneof=shipping direct" enum:"shipping,direct" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"`
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:"创建时间结束"`
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:已取消)"`
FlowType string `json:"flow_type" query:"flow_type" validate:"omitempty,oneof=shipping direct" enum:"shipping,direct" description:"换货流程类型 (shipping:物流换货, direct:直接换货)"`
OldAssetKeyword string `json:"old_asset_keyword" query:"old_asset_keyword" validate:"omitempty,max=100" maxLength:"100" description:"旧资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN与新资产关键词按 AND 组合"`
NewAssetKeyword string `json:"new_asset_keyword" query:"new_asset_keyword" validate:"omitempty,max=100" maxLength:"100" description:"新资产关键词,支持卡 ICCID、接入号、虚拟号或设备虚拟号、IMEI、SN与旧资产关键词按 AND 组合"`
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"`
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
}
// ExchangeShipRequest 换货发货请求。
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)"`
NewIdentifier string `json:"new_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"新资产输入标识,卡支持 ICCID、接入号、虚拟号设备支持虚拟号IMEISN;保存快照使用权威标识"`
MigrateData bool `json:"migrate_data" required:"true" description:"是否执行全量迁移 (true:执行, false:不执行)"`
}
@@ -62,6 +66,7 @@ type ClientShippingInfoParams struct {
ClientShippingInfoRequest
}
// ExchangeOrderResponse 换货单响应。
type ExchangeOrderResponse struct {
ID uint `json:"id" description:"换货单ID"`
ExchangeNo string `json:"exchange_no" description:"换货单号"`
@@ -69,10 +74,10 @@ type ExchangeOrderResponse struct {
FlowTypeName string `json:"flow_type_name" 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:"旧资产标识符"`
OldAssetIdentifier string `json:"old_asset_identifier" description:"旧资产权威快照,卡为完整 ICCID设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值"`
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:"新资产标识符"`
NewAssetIdentifier string `json:"new_asset_identifier" description:"新资产权威快照,卡为完整 ICCID设备按虚拟号、IMEI、SN 优先级取值;历史记录保持原值"`
RecipientName string `json:"recipient_name" description:"收件人姓名"`
RecipientPhone string `json:"recipient_phone" description:"收件人电话"`
RecipientAddress string `json:"recipient_address" description:"收货地址"`

View File

@@ -0,0 +1,133 @@
// Package exchange 提供换货读取用例。
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/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
)
// ListQuery 查询换货列表并完成权限、分页和响应投影。
type ListQuery struct {
db *gorm.DB
}
type assetSide string
const (
// oldAssetSide 表示旧资产查询侧。
oldAssetSide assetSide = "old"
// newAssetSide 表示新资产查询侧。
newAssetSide assetSide = "new"
)
// NewListQuery 创建换货列表查询。
func NewListQuery(db *gorm.DB) *ListQuery {
return &ListQuery{db: db}
}
// List 按新旧资产关键词和其他列表条件查询换货单。
func (q *ListQuery) List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) {
page := constants.DefaultPage
if req.Page != nil {
page = *req.Page
}
pageSize := constants.DefaultPageSize
if req.PageSize != nil {
pageSize = *req.PageSize
}
query := q.db.WithContext(ctx).Model(&model.ExchangeOrder{})
query = middleware.ApplyShopFilter(ctx, query)
query = applyListFilters(query, req)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单数量失败")
}
var orders []*model.ExchangeOrder
offset := (page - 1) * pageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&orders).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单列表失败")
}
items := make([]*dto.ExchangeOrderResponse, 0, len(orders))
for _, order := range orders {
items = append(items, projectExchangeOrder(order))
}
return &dto.ExchangeListResponse{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// applyListFilters 组装列表计数和数据查询共用的全部过滤条件。
func applyListFilters(query *gorm.DB, req *dto.ExchangeListRequest) *gorm.DB {
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.FlowType != "" {
query = query.Where("COALESCE(NULLIF(flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, req.FlowType)
}
query = applyAssetKeyword(query, oldAssetSide, req.OldAssetKeyword)
query = applyAssetKeyword(query, newAssetSide, req.NewAssetKeyword)
if req.CreatedAtStart != nil {
query = query.Where("created_at >= ?", *req.CreatedAtStart)
}
if req.CreatedAtEnd != nil {
query = query.Where("created_at <= ?", *req.CreatedAtEnd)
}
return query
}
// applyAssetKeyword 使用子查询按资产类型和主键命中,避免依赖历史快照内容或逐行读取资产。
func applyAssetKeyword(query *gorm.DB, side assetSide, keyword string) *gorm.DB {
if keyword == "" {
return query
}
like := "%" + keyword + "%"
cardIDs := query.Session(&gorm.Session{NewDB: true}).Model(&model.IotCard{}).
Select("id").
Where("iccid LIKE ? OR msisdn LIKE ? OR virtual_no LIKE ?", like, like, like)
deviceIDs := query.Session(&gorm.Session{NewDB: true}).Model(&model.Device{}).
Select("id").
Where("virtual_no LIKE ? OR imei LIKE ? OR sn LIKE ?", like, like, like)
prefix := string(side)
return query.Where(
"("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?)) OR ("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?))",
constants.ExchangeAssetTypeIotCard, cardIDs, constants.ExchangeAssetTypeDevice, deviceIDs,
)
}
// projectExchangeOrder 将只读模型投影为列表响应,不用于后续写侧判断。
func projectExchangeOrder(order *model.ExchangeOrder) *dto.ExchangeOrderResponse {
var deletedAt *time.Time
if order.DeletedAt.Valid {
deletedAt = &order.DeletedAt.Time
}
return &dto.ExchangeOrderResponse{
ID: order.ID, ExchangeNo: order.ExchangeNo,
FlowType: effectiveFlowType(order.FlowType), FlowTypeName: constants.GetExchangeFlowTypeName(order.FlowType),
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,
ShippedAt: order.ShippedAt, CompletedAt: order.CompletedAt,
ExchangeReason: order.ExchangeReason, Remark: order.Remark,
Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status), StatusText: constants.GetExchangeStatusName(order.Status),
ShopID: order.ShopID, CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, DeletedAt: deletedAt,
Creator: order.Creator, Updater: order.Updater,
}
}
func effectiveFlowType(flowType string) string {
if flowType == "" {
return constants.ExchangeFlowTypeShipping
}
return flowType
}

View File

@@ -0,0 +1,219 @@
package exchange
import (
"context"
"fmt"
"sort"
"sync/atomic"
"testing"
"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/testutil"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// TestListQuerySearchesOldAndNewAssetsIndependently 验证六类资产标识、新旧独立和双条件 AND。
func TestListQuerySearchesOldAndNewAssetsIndependently(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
query := NewListQuery(tx)
oldCard := createListTestCard(t, tx, 1, nil)
newCard := createListTestCard(t, tx, 2, nil)
oldDevice := createListTestDevice(t, tx, 1, nil)
newDevice := createListTestDevice(t, tx, 2, nil)
cardOrder := createListTestOrder(t, tx, "UR45-Q-CARD", oldCard.ID, constants.ExchangeAssetTypeIotCard, newCard.ID, constants.ExchangeAssetTypeIotCard, time.Now().Add(-time.Hour))
deviceOrder := createListTestOrder(t, tx, "UR45-Q-DEVICE", oldDevice.ID, constants.ExchangeAssetTypeDevice, newDevice.ID, constants.ExchangeAssetTypeDevice, time.Now())
for name, keyword := range map[string]string{"卡ICCID": oldCard.ICCID, "卡接入号": oldCard.MSISDN, "卡虚拟号": oldCard.VirtualNo} {
t.Run(name, func(t *testing.T) {
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: keyword}, []uint{cardOrder.ID})
})
}
for name, keyword := range map[string]string{"设备虚拟号": newDevice.VirtualNo, "设备IMEI": newDevice.IMEI, "设备SN": newDevice.SN} {
t.Run(name, func(t *testing.T) {
assertListQueryIDs(t, query, &dto.ExchangeListRequest{NewAssetKeyword: keyword}, []uint{deviceOrder.ID})
})
}
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: oldCard.MSISDN, NewAssetKeyword: newCard.VirtualNo}, []uint{cardOrder.ID})
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: oldCard.ICCID, NewAssetKeyword: newDevice.IMEI}, nil)
}
// TestListQueryCombinesFiltersAndPreservesHistoricalSnapshots 验证组合条件、历史快照和空结果契约。
func TestListQueryCombinesFiltersAndPreservesHistoricalSnapshots(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
query := NewListQuery(tx)
oldCard := createListTestCard(t, tx, 11, nil)
newCard := createListTestCard(t, tx, 12, nil)
createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
order := createListTestOrder(t, tx, "UR45-Q-HISTORY", oldCard.ID, constants.ExchangeAssetTypeIotCard, newCard.ID, constants.ExchangeAssetTypeIotCard, createdAt)
if err := tx.Model(order).Updates(map[string]any{
"old_asset_identifier": "历史旧快照", "new_asset_identifier": "历史新快照",
"status": constants.ExchangeStatusCompleted, "flow_type": constants.ExchangeFlowTypeDirect,
}).Error; err != nil {
t.Fatalf("更新历史快照失败:%v", err)
}
status := constants.ExchangeStatusCompleted
start, end := createdAt.Add(-time.Minute), createdAt.Add(time.Minute)
result, err := query.List(context.Background(), &dto.ExchangeListRequest{
OldAssetKeyword: oldCard.VirtualNo, NewAssetKeyword: newCard.MSISDN,
Status: &status, FlowType: constants.ExchangeFlowTypeDirect, CreatedAtStart: &start, CreatedAtEnd: &end,
})
if err != nil {
t.Fatalf("查询历史换货单失败:%v", err)
}
if result.Total != 1 || len(result.List) != 1 || result.List[0].OldAssetIdentifier != "历史旧快照" || result.List[0].NewAssetIdentifier != "历史新快照" {
t.Fatalf("历史快照查询结果错误:%+v", result)
}
assertListQueryIDs(t, query, &dto.ExchangeListRequest{OldAssetKeyword: "不存在"}, nil)
}
// TestListQueryExcludesDeletedAssetsAndAppliesShopScope 验证候选软删除和最终换货单店铺范围。
func TestListQueryExcludesDeletedAssetsAndAppliesShopScope(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
query := NewListQuery(tx)
shopOne, shopTwo := uint(45101), uint(45102)
cardOne := createListTestCard(t, tx, 21, &shopOne)
cardTwo := createListTestCard(t, tx, 22, &shopTwo)
orderOne := createListTestOrder(t, tx, "UR45-Q-SCOPE-1", cardOne.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now())
orderTwo := createListTestOrder(t, tx, "UR45-Q-SCOPE-2", cardTwo.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now().Add(time.Second))
if err := tx.Model(orderOne).Update("shop_id", shopOne).Error; err != nil {
t.Fatalf("更新店铺范围失败:%v", err)
}
if err := tx.Model(orderTwo).Update("shop_id", shopTwo).Error; err != nil {
t.Fatalf("更新店铺范围失败:%v", err)
}
ctx := context.WithValue(context.Background(), constants.ContextKeySubordinateShopIDs, []uint{shopOne})
result, err := query.List(ctx, &dto.ExchangeListRequest{OldAssetKeyword: "UR45-Q-CARD"})
if err != nil {
t.Fatalf("按店铺范围查询失败:%v", err)
}
if result.Total != 1 || result.List[0].ID != orderOne.ID {
t.Fatalf("店铺范围被关键词绕过:%+v", result)
}
if err := tx.Delete(cardOne).Error; err != nil {
t.Fatalf("软删除测试卡失败:%v", err)
}
result, err = query.List(ctx, &dto.ExchangeListRequest{OldAssetKeyword: cardOne.ICCID})
if err != nil || result.Total != 0 {
t.Fatalf("软删除候选资产不应命中result=%+v err=%v", result, err)
}
}
// TestListQueryUsesFixedQueriesAndConsistentPagination 验证大结果集仅执行计数和分页两条 SQL。
func TestListQueryUsesFixedQueriesAndConsistentPagination(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
card := createListTestCard(t, tx, 31, nil)
for index := 0; index < 120; index++ {
createListTestOrder(t, tx, fmt.Sprintf("UR45-Q-PERF-%03d", index), card.ID, constants.ExchangeAssetTypeIotCard, 0, "", time.Now().Add(time.Duration(index)*time.Second))
}
counter := &queryCounter{Interface: tx.Logger}
query := NewListQuery(tx.Session(&gorm.Session{Logger: counter}))
page, pageSize := 2, 20
req := &dto.ExchangeListRequest{OldAssetKeyword: card.ICCID, Page: &page, PageSize: &pageSize}
durations := make([]time.Duration, 100)
var result *dto.ExchangeListResponse
for index := range durations {
startedAt := time.Now()
var err error
result, err = query.List(context.Background(), req)
durations[index] = time.Since(startedAt)
if err != nil {
t.Fatalf("第 %d 次查询大结果集失败:%v", index+1, err)
}
}
if counter.count.Load() != 200 || result.Total != 120 || len(result.List) != 20 || result.List[0].ExchangeNo != "UR45-Q-PERF-099" {
t.Fatalf("查询次数或分页不一致queries=%d total=%d items=%d first=%s", counter.count.Load(), result.Total, len(result.List), result.List[0].ExchangeNo)
}
sort.Slice(durations, func(left, right int) bool { return durations[left] < durations[right] })
p95, p99 := durations[94], durations[98]
if p95 >= 200*time.Millisecond || p99 >= 500*time.Millisecond {
t.Fatalf("列表查询超过性能目标p95=%s p99=%s", p95, p99)
}
planSQL := query.db.ToSQL(func(db *gorm.DB) *gorm.DB {
return applyListFilters(db.Model(&model.ExchangeOrder{}), req).Order("created_at DESC").Offset(20).Limit(20).Find(&[]*model.ExchangeOrder{})
})
var planLines []string
if err := tx.Raw("EXPLAIN " + planSQL).Scan(&planLines).Error; err != nil || len(planLines) == 0 {
t.Fatalf("记录查询计划失败plan=%v err=%v", planLines, err)
}
}
// TestListQueryWrapsDatabaseErrors 验证数据库故障不会降级为空结果。
func TestListQueryWrapsDatabaseErrors(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
callbackName := "ur45:force_query_error"
if err := tx.Callback().Query().Before("gorm:query").Register(callbackName, func(db *gorm.DB) {
db.AddError(fmt.Errorf("UR45 模拟数据库故障"))
}); err != nil {
t.Fatalf("注册数据库故障回调失败:%v", err)
}
t.Cleanup(func() { _ = tx.Callback().Query().Remove(callbackName) })
_, err := NewListQuery(tx).List(context.Background(), &dto.ExchangeListRequest{})
appErr, ok := err.(*errors.AppError)
if !ok || appErr.Code != errors.CodeDatabaseError {
t.Fatalf("数据库故障应转换为统一错误,实际:%v", err)
}
}
type queryCounter struct {
logger.Interface
count atomic.Int64
}
func (l *queryCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
l.count.Add(1)
l.Interface.Trace(ctx, begin, fc, err)
}
func createListTestCard(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.IotCard {
t.Helper()
iccid := fmt.Sprintf("8986111111111111%04d", suffix)
card := &model.IotCard{ICCID: iccid, ICCID19: iccid[:19], MSISDN: fmt.Sprintf("1370000%04d", suffix), VirtualNo: fmt.Sprintf("UR45-Q-CARD-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
if err := tx.Create(card).Error; err != nil {
t.Fatalf("创建测试卡失败:%v", err)
}
return card
}
func createListTestDevice(t *testing.T, tx *gorm.DB, suffix int, shopID *uint) *model.Device {
t.Helper()
device := &model.Device{VirtualNo: fmt.Sprintf("UR45-Q-DEVICE-%04d", suffix), IMEI: fmt.Sprintf("86111111111%04d", suffix), SN: fmt.Sprintf("UR45-Q-SN-%04d", suffix), ShopID: shopID, AssetStatus: constants.AssetStatusInStock}
if err := tx.Create(device).Error; err != nil {
t.Fatalf("创建设备失败:%v", err)
}
return device
}
func createListTestOrder(t *testing.T, tx *gorm.DB, exchangeNo string, oldID uint, oldType string, newID uint, newType string, createdAt time.Time) *model.ExchangeOrder {
t.Helper()
order := &model.ExchangeOrder{ExchangeNo: exchangeNo, FlowType: constants.ExchangeFlowTypeShipping, OldAssetType: oldType, OldAssetID: oldID, OldAssetIdentifier: "历史旧快照", NewAssetType: newType, NewAssetIdentifier: "历史新快照", ExchangeReason: "UR45 查询测试", Status: constants.ExchangeStatusPendingInfo}
if newID > 0 {
order.NewAssetID = &newID
}
order.CreatedAt, order.UpdatedAt = createdAt, createdAt
if err := tx.Create(order).Error; err != nil {
t.Fatalf("创建换货单失败:%v", err)
}
return order
}
func assertListQueryIDs(t *testing.T, query *ListQuery, req *dto.ExchangeListRequest, expected []uint) {
t.Helper()
result, err := query.List(context.Background(), req)
if err != nil {
t.Fatalf("查询换货单失败:%v", err)
}
if result.Total != int64(len(expected)) || len(result.List) != len(expected) {
t.Fatalf("命中数量错误total=%d items=%d expected=%d", result.Total, len(result.List), len(expected))
}
for index, id := range expected {
if result.List[index].ID != id {
t.Fatalf("命中换货单错误:期望 %d实际 %d", id, result.List[index].ID)
}
}
}

View File

@@ -0,0 +1,243 @@
package exchange
import (
"context"
"fmt"
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/internal/testutil"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"go.uber.org/zap"
"gorm.io/gorm"
)
// TestResolveAssetByIdentifierUsesAuthoritativeSnapshot 验证任意受支持标识都生成权威换货快照。
func TestResolveAssetByIdentifierUsesAuthoritativeSnapshot(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
service := &Service{
iotCardStore: postgres.NewIotCardStore(tx, nil),
deviceStore: postgres.NewDeviceStore(tx, nil),
}
ctx := context.Background()
card := &model.IotCard{ICCID: "89860012345678901234", ICCID19: "8986001234567890123", MSISDN: "13800138000", VirtualNo: "UR45-CARD", AssetStatus: constants.AssetStatusInStock}
if err := tx.Create(card).Error; err != nil {
t.Fatalf("创建测试卡失败:%v", err)
}
for _, identifier := range []string{card.ICCID, card.MSISDN, card.VirtualNo} {
asset, err := service.resolveAssetByIdentifier(ctx, constants.ExchangeAssetTypeIotCard, identifier)
if err != nil {
t.Fatalf("通过 %s 解析测试卡失败:%v", identifier, err)
}
if asset.Identifier != card.ICCID {
t.Fatalf("卡快照应为 ICCID输入 %s实际 %s", identifier, asset.Identifier)
}
}
device := &model.Device{VirtualNo: "UR45-DEVICE", IMEI: "860000000000001", SN: "UR45-SN-1", AssetStatus: constants.AssetStatusInStock}
if err := tx.Create(device).Error; err != nil {
t.Fatalf("创建设备失败:%v", err)
}
asset, err := service.resolveAssetByIdentifier(ctx, constants.ExchangeAssetTypeDevice, device.IMEI)
if err != nil {
t.Fatalf("解析设备失败:%v", err)
}
if asset.Identifier != device.VirtualNo {
t.Fatalf("设备应优先保存虚拟号,实际 %s", asset.Identifier)
}
for _, testCase := range []struct {
device *model.Device
expected string
}{
{device: &model.Device{IMEI: "860000000000002", SN: "UR45-SN-2"}, expected: "860000000000002"},
{device: &model.Device{SN: "UR45-SN-3"}, expected: "UR45-SN-3"},
} {
if actual := newResolvedDeviceAsset(testCase.device).Identifier; actual != testCase.expected {
t.Fatalf("设备快照优先级错误,期望 %s实际 %s", testCase.expected, actual)
}
}
}
// TestExchangeWriteEntrypointsPersistAuthoritativeCardSnapshots 验证三个写入入口持久化卡的权威 ICCID。
func TestExchangeWriteEntrypointsPersistAuthoritativeCardSnapshots(t *testing.T) {
testCases := []struct {
name string
identifier func(*model.IotCard) string
}{
{name: "ICCID", identifier: func(card *model.IotCard) string { return card.ICCID }},
{name: "接入号", identifier: func(card *model.IotCard) string { return card.MSISDN }},
{name: "虚拟号", identifier: func(card *model.IotCard) string { return card.VirtualNo }},
}
for index, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
service := newExchangeSnapshotTestService(tx)
oldCard := createExchangeSnapshotCard(t, tx, index*10+1)
newCard := createExchangeSnapshotCard(t, tx, index*10+2)
direct, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
OldAssetType: constants.ExchangeAssetTypeIotCard,
OldIdentifier: testCase.identifier(oldCard),
FlowType: constants.ExchangeFlowTypeDirect,
NewIdentifier: testCase.identifier(newCard),
ExchangeReason: "UR45 卡快照测试",
})
if err != nil {
t.Fatalf("创建直接换货失败:%v", err)
}
if direct.OldAssetIdentifier != oldCard.ICCID || direct.NewAssetIdentifier != newCard.ICCID {
t.Fatalf("直接换货卡快照错误old=%s new=%s", direct.OldAssetIdentifier, direct.NewAssetIdentifier)
}
shippingOld := createExchangeSnapshotCard(t, tx, index*10+3)
shippingNew := createExchangeSnapshotCard(t, tx, index*10+4)
shipping, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
OldAssetType: constants.ExchangeAssetTypeIotCard,
OldIdentifier: testCase.identifier(shippingOld),
FlowType: constants.ExchangeFlowTypeShipping,
ExchangeReason: "UR45 物流换货快照测试",
})
if err != nil {
t.Fatalf("创建物流换货失败:%v", err)
}
if shipping.OldAssetIdentifier != shippingOld.ICCID {
t.Fatalf("物流换货旧卡快照应为 ICCID实际 %s", shipping.OldAssetIdentifier)
}
if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", shipping.ID).Updates(map[string]any{
"status": constants.ExchangeStatusPendingShip, "recipient_name": "测试用户",
"recipient_phone": "13800138000", "recipient_address": "测试地址",
}).Error; err != nil {
t.Fatalf("建立待发货测试前置状态失败:%v", err)
}
shipped, err := service.Ship(context.Background(), shipping.ID, &dto.ExchangeShipRequest{
ExpressCompany: "测试快递", ExpressNo: "UR45-EXPRESS", NewIdentifier: testCase.identifier(shippingNew),
})
if err != nil {
t.Fatalf("物流换货发货失败:%v", err)
}
if shipped.NewAssetIdentifier != shippingNew.ICCID {
t.Fatalf("物流换货新卡快照应为 ICCID实际 %s", shipped.NewAssetIdentifier)
}
})
}
}
// TestExchangeWriteEntrypointsPersistPreferredDeviceSnapshots 验证设备输入标识不影响稳定快照优先级。
func TestExchangeWriteEntrypointsPersistPreferredDeviceSnapshots(t *testing.T) {
testCases := []struct {
name string
identifier func(*model.Device) string
}{
{name: "虚拟号", identifier: func(device *model.Device) string { return device.VirtualNo }},
{name: "IMEI", identifier: func(device *model.Device) string { return device.IMEI }},
{name: "SN", identifier: func(device *model.Device) string { return device.SN }},
}
for index, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
service := newExchangeSnapshotTestService(tx)
oldDevice := createExchangeSnapshotDevice(t, tx, index*10+1, true, true)
newDevice := createExchangeSnapshotDevice(t, tx, index*10+2, true, true)
order, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
OldAssetType: constants.ExchangeAssetTypeDevice,
OldIdentifier: testCase.identifier(oldDevice),
FlowType: constants.ExchangeFlowTypeDirect,
NewIdentifier: testCase.identifier(newDevice),
ExchangeReason: "UR45 设备快照测试",
})
if err != nil {
t.Fatalf("创建设备直接换货失败:%v", err)
}
if order.OldAssetIdentifier != oldDevice.VirtualNo || order.NewAssetIdentifier != newDevice.VirtualNo {
t.Fatalf("设备快照应优先使用虚拟号old=%s new=%s", order.OldAssetIdentifier, order.NewAssetIdentifier)
}
persisted, err := service.Get(context.Background(), order.ID)
if err != nil || persisted.OldAssetIdentifier != oldDevice.VirtualNo || persisted.NewAssetIdentifier != newDevice.VirtualNo {
t.Fatalf("详情未读回设备权威快照order=%+v err=%v", persisted, err)
}
})
}
for index, testCase := range testCases {
t.Run("物流"+testCase.name, func(t *testing.T) {
tx := testutil.NewPostgresTransaction(t)
service := newExchangeSnapshotTestService(tx)
oldDevice := createExchangeSnapshotDevice(t, tx, 80+index*10+1, true, true)
newDevice := createExchangeSnapshotDevice(t, tx, 80+index*10+2, true, true)
shipping, err := service.Create(context.Background(), &dto.CreateExchangeRequest{
OldAssetType: constants.ExchangeAssetTypeDevice, OldIdentifier: testCase.identifier(oldDevice),
FlowType: constants.ExchangeFlowTypeShipping, ExchangeReason: "UR45 设备物流快照测试",
})
if err != nil {
t.Fatalf("创建设备物流换货失败:%v", err)
}
if shipping.OldAssetIdentifier != oldDevice.VirtualNo {
t.Fatalf("物流创建设备旧快照应使用虚拟号,实际 %s", shipping.OldAssetIdentifier)
}
if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", shipping.ID).Update("status", constants.ExchangeStatusPendingShip).Error; err != nil {
t.Fatalf("建立待发货状态失败:%v", err)
}
shipped, err := service.Ship(context.Background(), shipping.ID, &dto.ExchangeShipRequest{
ExpressCompany: "测试快递", ExpressNo: "UR45-DEVICE-EXPRESS", NewIdentifier: testCase.identifier(newDevice),
})
if err != nil {
t.Fatalf("设备物流换货发货失败:%v", err)
}
if shipped.OldAssetIdentifier != oldDevice.VirtualNo || shipped.NewAssetIdentifier != newDevice.VirtualNo {
t.Fatalf("设备物流快照应使用虚拟号old=%s new=%s", shipped.OldAssetIdentifier, shipped.NewAssetIdentifier)
}
})
}
}
func newExchangeSnapshotTestService(tx *gorm.DB) *Service {
iotCardStore := postgres.NewIotCardStore(tx, nil)
deviceStore := postgres.NewDeviceStore(tx, nil)
return New(
tx,
postgres.NewExchangeOrderStore(tx),
iotCardStore,
deviceStore,
postgres.NewAssetWalletStore(tx, nil),
postgres.NewAssetWalletTransactionStore(tx, nil),
postgres.NewPackageUsageStore(tx, nil),
postgres.NewPackageUsageDailyRecordStore(tx, nil),
postgres.NewResourceTagStore(tx),
customerBindingSvc.New(tx, iotCardStore, deviceStore),
zap.NewNop(),
)
}
func createExchangeSnapshotCard(t *testing.T, tx *gorm.DB, suffix int) *model.IotCard {
t.Helper()
iccid := fmt.Sprintf("8986000000000000%04d", suffix)
card := &model.IotCard{
ICCID: iccid, ICCID19: iccid[:19], MSISDN: fmt.Sprintf("1390000%04d", suffix),
VirtualNo: fmt.Sprintf("UR45-CARD-%04d", suffix), AssetStatus: constants.AssetStatusInStock,
}
if err := tx.Create(card).Error; err != nil {
t.Fatalf("创建测试卡失败:%v", err)
}
return card
}
func createExchangeSnapshotDevice(t *testing.T, tx *gorm.DB, suffix int, withVirtualNo, withIMEI bool) *model.Device {
t.Helper()
device := &model.Device{SN: fmt.Sprintf("UR45-SN-%04d", suffix), AssetStatus: constants.AssetStatusInStock}
if withVirtualNo {
device.VirtualNo = fmt.Sprintf("UR45-DEVICE-%04d", suffix)
}
if withIMEI {
device.IMEI = fmt.Sprintf("86000000000%04d", suffix)
}
if err := tx.Create(device).Error; err != nil {
t.Fatalf("创建设备失败:%v", err)
}
return device
}

View File

@@ -5,9 +5,9 @@ import (
"strings"
"time"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
"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"
@@ -112,47 +112,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
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.FlowType != "" {
filters["flow_type"] = normalizeExchangeFlowType(req.FlowType)
}
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 {
@@ -433,7 +392,7 @@ func (s *Service) resolveAssetByIdentifier(ctx context.Context, expectedAssetTyp
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
return newResolvedDeviceAsset(device), nil
}
if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
@@ -446,7 +405,7 @@ func (s *Service) resolveAssetByIdentifier(ctx context.Context, expectedAssetTyp
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
return newResolvedIotCardAsset(card), nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
@@ -684,7 +643,7 @@ func (s *Service) resolveAssetByID(ctx context.Context, assetType string, assetI
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: card.VirtualNo, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: card}, nil
return newResolvedIotCardAsset(card), nil
}
if assetType == constants.ExchangeAssetTypeDevice {
device, err := s.deviceStore.GetByID(ctx, assetID)
@@ -694,7 +653,7 @@ func (s *Service) resolveAssetByID(ctx context.Context, assetType string, assetI
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: preferredDeviceIdentifier(device), VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}, nil
return newResolvedDeviceAsset(device), nil
}
return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法")
}
@@ -710,7 +669,7 @@ func (s *Service) resolveAssetByIDWithTx(ctx context.Context, tx *gorm.DB, asset
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: card.VirtualNo, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: &card}, nil
return newResolvedIotCardAsset(&card), nil
}
if assetType == constants.ExchangeAssetTypeDevice {
var device model.Device
@@ -722,7 +681,7 @@ func (s *Service) resolveAssetByIDWithTx(ctx context.Context, tx *gorm.DB, asset
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: preferredDeviceIdentifier(&device), VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: &device}, nil
return newResolvedDeviceAsset(&device), nil
}
return nil, errors.New(errors.CodeInvalidParam, "资产类型不合法")
}
@@ -738,7 +697,7 @@ func (s *Service) resolveAssetByIdentifierWithTx(ctx context.Context, tx *gorm.D
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
return newResolvedDeviceAsset(&device), nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
@@ -753,7 +712,7 @@ func (s *Service) resolveAssetByIdentifierWithTx(ctx context.Context, tx *gorm.D
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
return newResolvedIotCardAsset(&card), nil
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
@@ -761,6 +720,16 @@ func (s *Service) resolveAssetByIdentifierWithTx(ctx context.Context, tx *gorm.D
return nil, errors.New(errors.CodeAssetNotFound)
}
// newResolvedIotCardAsset 将卡的权威 ICCID 固化为换货快照,避免请求标识污染历史记录。
func newResolvedIotCardAsset(card *model.IotCard) *resolvedExchangeAsset {
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: card.ICCID, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: card}
}
// newResolvedDeviceAsset 按虚拟号、IMEI、SN 的稳定优先级生成设备换货快照。
func newResolvedDeviceAsset(device *model.Device) *resolvedExchangeAsset {
return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: preferredDeviceIdentifier(device), VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}
}
func (s *Service) ensureNoActiveExchangeWithTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
var count int64
query := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).

View File

@@ -3,7 +3,6 @@ package postgres
import (
"context"
"maps"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -33,52 +32,6 @@ func (s *ExchangeOrderStore) GetByID(ctx context.Context, id uint) (*model.Excha
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 flowType, ok := filters["flow_type"].(string); ok && flowType != "" {
query = query.Where("COALESCE(NULLIF(flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, flowType)
}
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)

View File

@@ -0,0 +1,39 @@
// Package testutil 提供项目集成测试共用的真实基础设施连接。
package testutil
import (
"os"
"testing"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/database"
"go.uber.org/zap"
"gorm.io/gorm"
)
// NewPostgresTransaction 创建自动回滚的 PostgreSQL 测试事务。
func NewPostgresTransaction(t *testing.T) *gorm.DB {
t.Helper()
if os.Getenv("JUNHONG_DATABASE_HOST") == "" {
t.Skip("未加载 .env.local跳过依赖真实 PostgreSQL 的集成测试")
}
cfg, err := config.Load()
if err != nil {
t.Fatalf("加载测试配置失败:%v", err)
}
db, err := database.InitPostgreSQL(&cfg.Database, zap.NewNop())
if err != nil {
t.Fatalf("连接 PostgreSQL 失败:%v", err)
}
tx := db.Begin()
if tx.Error != nil {
t.Fatalf("开启测试事务失败:%v", tx.Error)
}
t.Cleanup(func() {
_ = tx.Rollback().Error
if sqlDB, dbErr := db.DB(); dbErr == nil {
_ = sqlDB.Close()
}
})
return tx
}

View File

@@ -50,7 +50,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil),
AdminOrder: admin.NewOrderHandler(nil, nil),
AdminExchange: admin.NewExchangeHandler(nil, nil),
AdminExchange: admin.NewExchangeHandler(nil, nil, nil),
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil, nil, nil, nil, nil),
PollingConfig: admin.NewPollingConfigHandler(nil),
PollingConcurrency: admin.NewPollingConcurrencyHandler(nil),