完成UR46后端任务收口
This commit is contained in:
@@ -92,6 +92,38 @@ func TestCalculate(t *testing.T) {
|
||||
return []*model.PackageUsage{current, queued}
|
||||
}(), wantStatus: constants.PackageExpiryEstimateStatusInvalidData,
|
||||
},
|
||||
{
|
||||
name: "已过期退款软删除加油包均排除", usages: func() []*model.PackageUsage {
|
||||
expired := newUsage(1, constants.PackageUsageStatusExpired, 1)
|
||||
invalidated := newUsage(2, constants.PackageUsageStatusInvalidated, 2)
|
||||
refundID := uint(3)
|
||||
refunded := newUsage(3, constants.PackageUsageStatusActive, 3)
|
||||
refunded.RefundID = &refundID
|
||||
deleted := newUsage(4, constants.PackageUsageStatusActive, 4)
|
||||
deleted.DeletedAt = gorm.DeletedAt{Time: now, Valid: true}
|
||||
masterID := uint(1)
|
||||
addon := newUsage(5, constants.PackageUsageStatusActive, 5)
|
||||
addon.MasterUsageID = &masterID
|
||||
return []*model.PackageUsage{expired, invalidated, refunded, deleted, addon}
|
||||
}(), wantStatus: constants.PackageExpiryEstimateStatusNone,
|
||||
},
|
||||
{
|
||||
name: "多个当前套餐为异常数据", usages: func() []*model.PackageUsage {
|
||||
first := newUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
first.ExpiresAt = ¤tExpiry
|
||||
second := newUsage(2, constants.PackageUsageStatusDepleted, 2)
|
||||
second.ExpiresAt = ¤tExpiry
|
||||
return []*model.PackageUsage{first, second}
|
||||
}(), wantStatus: constants.PackageExpiryEstimateStatusInvalidData,
|
||||
},
|
||||
{
|
||||
name: "已过期当前套餐返回负数自然日", usages: func() []*model.PackageUsage {
|
||||
expiry := time.Date(2026, 7, 20, 23, 59, 59, 0, location)
|
||||
u := newUsage(1, constants.PackageUsageStatusActive, 1)
|
||||
u.ExpiresAt = &expiry
|
||||
return []*model.PackageUsage{u}
|
||||
}(), wantStatus: constants.PackageExpiryEstimateStatusExact, wantDays: intPointer(-3),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -158,6 +190,25 @@ func TestQueryResolveBatchUsesOneBatchLoad(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryIndexesExplainAnalyze 验证开发 PostgreSQL 为两类资产队列查询选择 UR46 部分索引。
|
||||
func TestQueryIndexesExplainAnalyze(t *testing.T) {
|
||||
tx := testutil.NewPostgresTransaction(t)
|
||||
queries := []string{
|
||||
"EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE iot_card_id = 970001 AND master_usage_id IS NULL AND refund_id IS NULL AND status IN (0, 1, 2) ORDER BY priority ASC, created_at ASC, id ASC",
|
||||
"EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE device_id = 970001 AND master_usage_id IS NULL AND refund_id IS NULL AND status IN (0, 1, 2) ORDER BY priority ASC, created_at ASC, id ASC",
|
||||
}
|
||||
for _, query := range queries {
|
||||
var lines []string
|
||||
if err := tx.Raw(query).Scan(&lines).Error; err != nil {
|
||||
t.Fatalf("执行 EXPLAIN ANALYZE 失败:%v", err)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
t.Fatal("EXPLAIN ANALYZE 未返回执行计划")
|
||||
}
|
||||
t.Logf("%s", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func modelBase(id uint, createdAt time.Time) gorm.Model {
|
||||
return gorm.Model{ID: id, CreatedAt: createdAt}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -18,10 +19,15 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
|
||||
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
|
||||
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
@@ -111,6 +117,135 @@ func TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace(t *
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssetResolveHTTPReturnsPackageExpiryEstimate 验证后台详情通过真实 JWT、Redis、Fiber 与 PostgreSQL 返回预计最终到期字段。
|
||||
func TestAssetResolveHTTPReturnsPackageExpiryEstimate(t *testing.T) {
|
||||
env := newAssetTraceHTTPEnv(t)
|
||||
shop := env.createShop(t, "UR46详情店铺", nil, 1)
|
||||
card := env.createCard(t, 46, &shop.ID)
|
||||
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
expiresAt := now.AddDate(0, 0, 5)
|
||||
usage := &model.PackageUsage{OrderID: card.ID, OrderNo: "UR46-HTTP", PackageID: card.ID, UsageType: constants.AssetTypeIotCard, IotCardID: card.ID, DataLimitMB: 1, Status: constants.PackageUsageStatusActive, Priority: 1, ActivatedAt: &now, ExpiresAt: &expiresAt, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
|
||||
if err := env.db.Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建详情套餐使用记录失败:%v", err)
|
||||
}
|
||||
status, body := env.request(t, "/api/admin/assets/resolve/"+card.ICCID, env.newAgentToken(t, shop.ID))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("查询资产详情失败:%d %s", status, body)
|
||||
}
|
||||
var response struct {
|
||||
Data struct {
|
||||
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
|
||||
Days *int `json:"days_until_final_expiry"`
|
||||
Status string `json:"expiry_estimate_status"`
|
||||
Name string `json:"expiry_estimate_status_name"`
|
||||
Expiring bool `json:"is_expiring"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||
t.Fatalf("解析预计到期响应失败:%v", err)
|
||||
}
|
||||
if response.Data.ExpiresAt == nil || response.Data.Days == nil || response.Data.Status != constants.PackageExpiryEstimateStatusExact || response.Data.Name == "" || !response.Data.Expiring {
|
||||
t.Fatalf("预计到期字段不完整:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientAssetInfoHTTPReturnsPackageExpiryEstimate 验证 C 端经真实 JWT、Redis 和 Fiber 返回与后台一致的预计到期字段。
|
||||
func TestClientAssetInfoHTTPReturnsPackageExpiryEstimate(t *testing.T) {
|
||||
env := newAssetTraceHTTPEnv(t)
|
||||
card := env.createCard(t, 47, nil)
|
||||
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
expiresAt := now.AddDate(0, 0, 5)
|
||||
usage := &model.PackageUsage{OrderID: card.ID, OrderNo: "UR46-CLIENT", PackageID: card.ID, UsageType: constants.AssetTypeIotCard, IotCardID: card.ID, DataLimitMB: 1, Status: constants.PackageUsageStatusActive, Priority: 1, ActivatedAt: &now, ExpiresAt: &expiresAt, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
|
||||
if err := env.db.Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建 C 端套餐使用记录失败:%v", err)
|
||||
}
|
||||
customer := &model.PersonalCustomer{WxOpenID: "ur46-client-openid-" + strconv.FormatInt(time.Now().UnixNano(), 10), WxUnionID: "ur46-client-union", Status: constants.StatusEnabled}
|
||||
if err := env.db.Create(customer).Error; err != nil {
|
||||
t.Fatalf("创建 C 端客户失败:%v", err)
|
||||
}
|
||||
if err := env.db.Create(&model.PersonalCustomerDevice{CustomerID: customer.ID, VirtualNo: card.VirtualNo, BindAt: now, Status: constants.StatusEnabled}).Error; err != nil {
|
||||
t.Fatalf("创建 C 端资产绑定失败:%v", err)
|
||||
}
|
||||
jwtManager := auth.NewJWTManager("ur46-client-test-secret", time.Hour)
|
||||
token, err := jwtManager.GeneratePersonalCustomerToken(customer.ID, "13800000000", constants.AssetTypeIotCard, card.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("生成 C 端 JWT 失败:%v", err)
|
||||
}
|
||||
if err := env.redis.Set(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID), token, time.Hour).Err(); err != nil {
|
||||
t.Fatalf("写入 C 端 Redis Token 失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = env.redis.Del(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID)).Err()
|
||||
})
|
||||
cardStore := postgres.NewIotCardStore(env.db, env.redis)
|
||||
deviceStore := postgres.NewDeviceStore(env.db, env.redis)
|
||||
clientHandler := apphandler.NewClientAssetHandler(env.assetService, customerBinding.New(env.db, cardStore, deviceStore), postgres.NewAssetWalletStore(env.db, env.redis), postgres.NewPackageStore(env.db), postgres.NewShopPackageAllocationStore(env.db), cardStore, deviceStore, env.db, zap.NewNop())
|
||||
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(zap.NewNop())})
|
||||
personalAuth := internalMiddleware.NewPersonalAuthMiddleware(jwtManager, env.redis, zap.NewNop())
|
||||
app.Get("/api/c/v1/asset/info", personalAuth.Authenticate(), clientHandler.GetAssetInfo)
|
||||
request, err := http.NewRequest(http.MethodGet, "/api/c/v1/asset/info?identifier="+card.ICCID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 C 端请求失败:%v", err)
|
||||
}
|
||||
request.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
|
||||
response, err := app.Test(request, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("执行 C 端请求失败:%v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if response.StatusCode != http.StatusOK || !bytes.Contains(body, []byte(`"expiry_estimate_status":"exact"`)) {
|
||||
t.Fatalf("C 端预计到期响应错误:%d %s", response.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssetListHTTPReturnsPackageExpiryEstimate 验证卡、设备列表在真实 JWT、Redis、Fiber 下批量返回统一预计到期字段。
|
||||
func TestAssetListHTTPReturnsPackageExpiryEstimate(t *testing.T) {
|
||||
env := newAssetTraceHTTPEnv(t)
|
||||
shopStore := postgres.NewShopStore(env.db, env.redis)
|
||||
cardStore := postgres.NewIotCardStore(env.db, env.redis)
|
||||
deviceStore := postgres.NewDeviceStore(env.db, env.redis)
|
||||
seriesStore := postgres.NewPackageSeriesStore(env.db)
|
||||
allocationStore := postgres.NewAssetAllocationRecordStore(env.db, env.redis)
|
||||
cardSvc := iotCardService.New(env.db, cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(env.db), postgres.NewShopSeriesAllocationStore(env.db), seriesStore, nil, zap.NewNop(), nil)
|
||||
deviceSvc := deviceService.New(env.db, env.redis, deviceStore, postgres.NewDeviceSimBindingStore(env.db, env.redis), cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(env.db), postgres.NewShopSeriesAllocationStore(env.db), seriesStore, nil, postgres.NewAssetIdentifierStore(env.db), nil, postgres.NewEnterpriseDeviceAuthorizationStore(env.db, env.redis), postgres.NewEnterpriseStore(env.db, env.redis))
|
||||
packageExpiry := packageExpiryQuery.NewQuery(env.db)
|
||||
cardSvc.SetPackageExpiryQuery(packageExpiry)
|
||||
deviceSvc.SetPackageExpiryQuery(packageExpiry)
|
||||
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
|
||||
info, err := env.tokenManager.ValidateAccessToken(context.Background(), token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pkgMiddleware.UserContextInfo{UserID: info.UserID, UserType: info.UserType, ShopID: info.ShopID}, nil
|
||||
}, ShopStore: shopStore})
|
||||
env.app.Get("/api/admin/iot-cards/standalone", authMiddleware, admin.NewIotCardHandler(cardSvc).ListStandalone)
|
||||
env.app.Get("/api/admin/devices", authMiddleware, admin.NewDeviceHandler(deviceSvc).List)
|
||||
card := env.createCard(t, 48, nil)
|
||||
card.IsStandalone = true
|
||||
if err := env.db.Model(card).Update("is_standalone", true).Error; err != nil {
|
||||
t.Fatalf("设置独立卡测试状态失败:%v", err)
|
||||
}
|
||||
device := &model.Device{VirtualNo: "UR46-LIST-DEVICE-" + strconv.FormatInt(time.Now().UnixNano(), 10), Status: constants.DeviceStatusInStock, AssetStatus: constants.AssetStatusInStock}
|
||||
if err := env.db.Create(device).Error; err != nil {
|
||||
t.Fatalf("创建设备失败:%v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
expiresAt := now.AddDate(0, 0, 5)
|
||||
for _, usage := range []*model.PackageUsage{{OrderID: card.ID, OrderNo: "UR46-LIST-CARD", PackageID: card.ID, UsageType: constants.AssetTypeIotCard, IotCardID: card.ID, DataLimitMB: 1, Status: constants.PackageUsageStatusActive, Priority: 1, ActivatedAt: &now, ExpiresAt: &expiresAt, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}, {OrderID: device.ID, OrderNo: "UR46-LIST-DEVICE", PackageID: device.ID, UsageType: constants.AssetTypeDevice, DeviceID: device.ID, DataLimitMB: 1, Status: constants.PackageUsageStatusActive, Priority: 1, ActivatedAt: &now, ExpiresAt: &expiresAt, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}} {
|
||||
if err := env.db.Create(usage).Error; err != nil {
|
||||
t.Fatalf("创建列表套餐失败:%v", err)
|
||||
}
|
||||
}
|
||||
token := env.newAgentToken(t, 0)
|
||||
for _, path := range []string{"/api/admin/iot-cards/standalone?page=1&page_size=100", "/api/admin/devices?page=1&page_size=100"} {
|
||||
status, body := env.request(t, path, token)
|
||||
if status != http.StatusOK || !bytes.Contains(body, []byte(`"expiry_estimate_status":"exact"`)) {
|
||||
t.Fatalf("列表预计到期响应错误:path=%s status=%d body=%s", path, status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertHTTPTraceDirections(t *testing.T, status int, body []byte, hasPrevious, hasNext bool) {
|
||||
t.Helper()
|
||||
if status != http.StatusOK {
|
||||
@@ -137,6 +272,7 @@ type assetTraceHTTPEnv struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
tokenManager *auth.TokenManager
|
||||
assetService *assetService.Service
|
||||
}
|
||||
|
||||
func newAssetTraceHTTPEnv(t *testing.T) *assetTraceHTTPEnv {
|
||||
@@ -183,6 +319,7 @@ func newAssetTraceHTTPEnv(t *testing.T) *assetTraceHTTPEnv {
|
||||
postgres.NewAssetIdentifierStore(tx), postgres.NewOrderStore(tx, redisClient), postgres.NewOrderItemStore(tx, redisClient),
|
||||
postgres.NewExchangeOrderStore(tx), nil,
|
||||
)
|
||||
assetSvc.SetPackageExpiryQuery(packageExpiryQuery.NewQuery(tx))
|
||||
handler := admin.NewAssetHandler(assetSvc, nil, nil, nil, nil, nil, assetQuery.NewExchangeTraceQuery(tx, log))
|
||||
tokenManager := auth.NewTokenManager(redisClient, cfg.JWT.AccessTokenTTL, cfg.JWT.RefreshTokenTTL)
|
||||
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{
|
||||
@@ -197,7 +334,7 @@ func newAssetTraceHTTPEnv(t *testing.T) *assetTraceHTTPEnv {
|
||||
})
|
||||
app := fiber.New(fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal, ErrorHandler: internalMiddleware.ErrorHandler(log)})
|
||||
RegisterAdminRoutes(app.Group("/api/admin"), &bootstrap.Handlers{Asset: handler}, &bootstrap.Middlewares{AdminAuth: authMiddleware}, nil, "/api/admin")
|
||||
return &assetTraceHTTPEnv{app: app, db: tx, redis: redisClient, tokenManager: tokenManager}
|
||||
return &assetTraceHTTPEnv{app: app, db: tx, redis: redisClient, tokenManager: tokenManager, assetService: assetSvc}
|
||||
}
|
||||
|
||||
func (e *assetTraceHTTPEnv) createShop(t *testing.T, name string, parentID *uint, level int) *model.Shop {
|
||||
|
||||
Reference in New Issue
Block a user