diff --git a/README.md b/README.md index 24f691a..0a8d103 100644 --- a/README.md +++ b/README.md @@ -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)和 H5(Agent、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)和 H5(Agent、Enterprise)的访问控制;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md) - **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户 diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index c9be5f6..76cb665 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -3323,7 +3323,7 @@ components: nullable: true type: boolean new_identifier: - description: 新资产标识符,direct 流程必填(ICCID/虚拟号/IMEI/SN) + description: 新资产输入标识,direct 流程必填;卡支持 ICCID、接入号、虚拟号,设备支持虚拟号、IMEI、SN 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、接入号、虚拟号,设备支持虚拟号、IMEI、SN;响应快照使用权威标识 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、接入号、虚拟号,设备支持虚拟号、IMEI、SN;保存快照使用权威标识 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: 创建时间起始 diff --git a/docs/ur45-exchange-asset-search/功能总结.md b/docs/ur45-exchange-asset-search/功能总结.md new file mode 100644 index 0000000..8cf83b1 --- /dev/null +++ b/docs/ur45-exchange-asset-search/功能总结.md @@ -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、查询耗时和空结果比例;异常时按应用版本整体回滚,不执行数据修复。 diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 8f57aad..dd937d7 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -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), } } diff --git a/internal/handler/admin/exchange.go b/internal/handler/admin/exchange.go index 4f9f206..72e9651 100644 --- a/internal/handler/admin/exchange.go +++ b/internal/handler/admin/exchange.go @@ -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 { diff --git a/internal/handler/admin/exchange_test.go b/internal/handler/admin/exchange_test.go new file mode 100644 index 0000000..126225e --- /dev/null +++ b/internal/handler/admin/exchange_test.go @@ -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) + } +} diff --git a/internal/model/dto/exchange_dto.go b/internal/model/dto/exchange_dto.go index 201f371..2caf8f2 100644 --- a/internal/model/dto/exchange_dto.go +++ b/internal/model/dto/exchange_dto.go @@ -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、接入号、虚拟号,设备支持虚拟号、IMEI、SN;响应快照使用权威标识"` 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、接入号、虚拟号,设备支持虚拟号、IMEI、SN"` 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、接入号、虚拟号,设备支持虚拟号、IMEI、SN;保存快照使用权威标识"` 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:"收货地址"` diff --git a/internal/query/exchange/list.go b/internal/query/exchange/list.go new file mode 100644 index 0000000..66dd695 --- /dev/null +++ b/internal/query/exchange/list.go @@ -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 +} diff --git a/internal/query/exchange/list_test.go b/internal/query/exchange/list_test.go new file mode 100644 index 0000000..d54d59d --- /dev/null +++ b/internal/query/exchange/list_test.go @@ -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) + } + } +} diff --git a/internal/service/exchange/asset_snapshot_test.go b/internal/service/exchange/asset_snapshot_test.go new file mode 100644 index 0000000..6e3727f --- /dev/null +++ b/internal/service/exchange/asset_snapshot_test.go @@ -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 +} diff --git a/internal/service/exchange/service.go b/internal/service/exchange/service.go index f72d0cc..c567b5d 100644 --- a/internal/service/exchange/service.go +++ b/internal/service/exchange/service.go @@ -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{}). diff --git a/internal/store/postgres/exchange_order_store.go b/internal/store/postgres/exchange_order_store.go index c6f177b..bafb73d 100644 --- a/internal/store/postgres/exchange_order_store.go +++ b/internal/store/postgres/exchange_order_store.go @@ -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) diff --git a/internal/testutil/database.go b/internal/testutil/database.go new file mode 100644 index 0000000..f9e7e6b --- /dev/null +++ b/internal/testutil/database.go @@ -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 +} diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 0cc42ec..ba5dcba 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -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),