实现换货资产快照与新旧独立搜索
This commit is contained in:
@@ -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 {
|
||||
|
||||
252
internal/handler/admin/exchange_test.go
Normal file
252
internal/handler/admin/exchange_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user