补齐UR46后端验收测试
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m55s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m55s
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Blocked by:** `.scratch/ur55-package-expiry-base/issues/05-activate-and-queue-from-package-snapshots.md` — 05 — 套餐激活与队列接续只消费购买快照
|
**Blocked by:** `.scratch/ur55-package-expiry-base/issues/05-activate-and-queue-from-package-snapshots.md` — 05 — 套餐激活与队列接续只消费购买快照
|
||||||
|
|
||||||
**Status:** backend-implemented(补充验收测试后结项)
|
**Status:** done(后端)
|
||||||
|
|
||||||
- [x] 架构主通道为 Query,Infrastructure 仅负责 GORM 批量读取、历史套餐兜底和索引;完整收口预计最终到期的读取与投影边界,不迁移套餐激活、购买、退款或队列接续写逻辑。
|
- [x] 架构主通道为 Query,Infrastructure 仅负责 GORM 批量读取、历史套餐兜底和索引;完整收口预计最终到期的读取与投影边界,不迁移套餐激活、购买、退款或队列接续写逻辑。
|
||||||
- [x] 建立可供单资产和批量资产入口共同调用的纯计算核心;两种入口对同一组使用记录返回完全一致的状态、预计日期、剩余自然日和临期标记,卡、设备及各端不得各自实现推算规则。
|
- [x] 建立可供单资产和批量资产入口共同调用的纯计算核心;两种入口对同一组使用记录返回完全一致的状态、预计日期、剩余自然日和临期标记,卡、设备及各端不得各自实现推算规则。
|
||||||
@@ -16,6 +16,6 @@
|
|||||||
- [x] 后台统一资产解析接口为卡和设备返回预计日期、剩余天数、状态、状态中文名称及临期标记;查询失败向上返回统一错误,不得静默返回 `none`。
|
- [x] 后台统一资产解析接口为卡和设备返回预计日期、剩余天数、状态、状态中文名称及临期标记;查询失败向上返回统一错误,不得静默返回 `none`。
|
||||||
- [ ] 后台卡和设备详情统一显示“预计套餐到期时间”:精确日期、待激活后起算、—、数据异常分别对应四种状态;当前套餐自身到期时间只保留在套餐明细中。(前端任务,不纳入后端交付)
|
- [ ] 后台卡和设备详情统一显示“预计套餐到期时间”:精确日期、待激活后起算、—、数据异常分别对应四种状态;当前套餐自身到期时间只保留在套餐明细中。(前端任务,不纳入后端交付)
|
||||||
- [x] 为未软删除主套餐的资产、状态和队列读取建立经开发库 `EXPLAIN ANALYZE` 验证的非唯一或部分索引;索引迁移包含中文注释、可逆向下迁移,代表性查询满足项目性能目标。
|
- [x] 为未软删除主套餐的资产、状态和队列读取建立经开发库 `EXPLAIN ANALYZE` 验证的非唯一或部分索引;索引迁移包含中文注释、可逆向下迁移,代表性查询满足项目性能目标。
|
||||||
- [ ] 纯计算测试覆盖无套餐、仅当前套餐、多段队列、自然月、按天、月末、跨年、等待实名激活、历史回退、非法快照、重复优先级、退款、失效、软删除和加油包排除,并明确验证接续边界不多算或少算一天。(后端验收测试待补)
|
- [x] 纯计算测试覆盖无套餐、仅当前套餐、多段队列、自然月、按天、月末、跨年、等待实名激活、历史回退、非法快照、重复优先级、退款、失效、软删除和加油包排除,并明确验证接续边界不多算或少算一天。
|
||||||
- [ ] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成后台卡/设备详情集成验证;新增、退款或失效排队套餐后无需刷新快照,下一次查询立即返回新结果。(后端验收测试待补)
|
- [x] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成后台卡/设备详情集成验证;新增、退款或失效排队套餐后无需刷新快照,下一次查询立即返回新结果。
|
||||||
- [x] 补充本功能中文总结文档并同步更新 README;不实现 UR#33 临期列表、Dashboard、提醒,不实现 UR#42 导出,也不维护资产级最终到期快照字段。
|
- [x] 补充本功能中文总结文档并同步更新 README;不实现 UR#33 临期列表、Dashboard、提醒,不实现 UR#42 导出,也不维护资产级最终到期快照字段。
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Blocked by:** `.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md` — 01 — 后台资产详情统一展示预计最终到期
|
**Blocked by:** `.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md` — 01 — 后台资产详情统一展示预计最终到期
|
||||||
|
|
||||||
**Status:** backend-implemented(补充验收测试后结项)
|
**Status:** done(后端)
|
||||||
|
|
||||||
- [x] 架构主通道为 Query;复用 01 已建立的批量入口和纯计算核心,完整收口普通卡列表的预计最终到期投影,不迁移卡管理写操作、套餐生命周期或未触碰的列表逻辑。
|
- [x] 架构主通道为 Query;复用 01 已建立的批量入口和纯计算核心,完整收口普通卡列表的预计最终到期投影,不迁移卡管理写操作、套餐生命周期或未触碰的列表逻辑。
|
||||||
- [x] 卡列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。
|
- [x] 卡列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。
|
||||||
@@ -13,5 +13,5 @@
|
|||||||
- [x] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通卡列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。
|
- [x] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通卡列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。
|
||||||
- [ ] 后台和代理端卡列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。(前端任务,不纳入后端交付)
|
- [ ] 后台和代理端卡列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。(前端任务,不纳入后端交付)
|
||||||
- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、0~3 天置顶、Dashboard 或通知行为。(前端任务,不纳入后端交付)
|
- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、0~3 天置顶、Dashboard 或通知行为。(前端任务,不纳入后端交付)
|
||||||
- [ ] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 张卡的代表性数据验证固定查询数量并满足项目性能目标。(后端验收测试待补)
|
- [x] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 张卡的代表性数据验证固定查询数量并满足项目性能目标。
|
||||||
- [ ] 前端验收普通卡列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。(前端任务,不纳入后端交付)
|
- [ ] 前端验收普通卡列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。(前端任务,不纳入后端交付)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Blocked by:** `.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md` — 01 — 后台资产详情统一展示预计最终到期
|
**Blocked by:** `.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md` — 01 — 后台资产详情统一展示预计最终到期
|
||||||
|
|
||||||
**Status:** backend-implemented(补充验收测试后结项)
|
**Status:** done(后端)
|
||||||
|
|
||||||
- [x] 架构主通道为 Query;复用 01 已建立的批量入口和纯计算核心,完整收口普通设备列表的预计最终到期投影,不迁移设备绑定、设备管理写操作、套餐生命周期或未触碰的列表逻辑。
|
- [x] 架构主通道为 Query;复用 01 已建立的批量入口和纯计算核心,完整收口普通设备列表的预计最终到期投影,不迁移设备绑定、设备管理写操作、套餐生命周期或未触碰的列表逻辑。
|
||||||
- [x] 设备列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。
|
- [x] 设备列表每项明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;非 `exact` 时 nullable 字段返回 `null`,查询错误遵循统一错误规范。
|
||||||
@@ -13,5 +13,5 @@
|
|||||||
- [x] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通设备列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。
|
- [x] Count 与 Fetch 保持原有相同过滤条件,新增投影不改变总数;普通设备列表继续使用原有稳定排序,不按预计日期或临期程度重新排序。
|
||||||
- [ ] 后台和代理端设备列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。(前端任务,不纳入后端交付)
|
- [ ] 后台和代理端设备列表统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;前端不叠加套餐时长或自行计算剩余天数。(前端任务,不纳入后端交付)
|
||||||
- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、0~3 天置顶、Dashboard 或通知行为。(前端任务,不纳入后端交付)
|
- [ ] `is_expiring=true` 时仅应用约定临期颜色,不引入 UR#33 的独立临期列表、0~3 天置顶、Dashboard 或通知行为。(前端任务,不纳入后端交付)
|
||||||
- [ ] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 台设备的代表性数据验证固定查询数量并满足项目性能目标。(后端验收测试待补)
|
- [x] HTTP 集成测试覆盖四种状态、nullable 字段、详情与列表一致性、原筛选和分页不变;使用每页 100 台设备的代表性数据验证固定查询数量并满足项目性能目标。
|
||||||
- [ ] 前端验收普通设备列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。(前端任务,不纳入后端交付)
|
- [ ] 前端验收普通设备列表排序不变、翻页稳定,当前套餐自身到期时间不再作为资产摘要中的第二个汇总到期字段。(前端任务,不纳入后端交付)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Blocked by:** `.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md` — 01 — 后台资产详情统一展示预计最终到期
|
**Blocked by:** `.scratch/ur46-estimated-final-expiry/issues/01-admin-asset-detail-estimated-final-expiry.md` — 01 — 后台资产详情统一展示预计最终到期
|
||||||
|
|
||||||
**Status:** backend-implemented(补充验收测试后结项)
|
**Status:** done(后端)
|
||||||
|
|
||||||
- [x] 架构主通道为 Query,辅助通道为 C 端接口投影;复用 01 的单资产入口和纯计算核心,完整收口 C 端资产信息展示,不迁移购买、实名、续费、退款或套餐激活写逻辑。
|
- [x] 架构主通道为 Query,辅助通道为 C 端接口投影;复用 01 的单资产入口和纯计算核心,完整收口 C 端资产信息展示,不迁移购买、实名、续费、退款或套餐激活写逻辑。
|
||||||
- [x] C 端卡和设备资产信息明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;字段语义、RFC3339 序列化及 nullable 行为与后台完全一致。
|
- [x] C 端卡和设备资产信息明确返回预计日期、剩余自然日、推算状态、状态中文名称和临期标记;字段语义、RFC3339 序列化及 nullable 行为与后台完全一致。
|
||||||
@@ -12,5 +12,5 @@
|
|||||||
- [ ] C 端资产摘要统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;异常不得伪装成无套餐或空白日期。(前端任务,不纳入后端交付)
|
- [ ] C 端资产摘要统一显示“预计套餐到期时间”,四种状态分别展示精确日期、待激活后起算、—、数据异常;异常不得伪装成无套餐或空白日期。(前端任务,不纳入后端交付)
|
||||||
- [ ] 套餐明细中的当前套餐到期时间继续保留用于解释当前周期,但不得与预计最终到期并列为两个资产汇总口径。(前端任务,不纳入后端交付)
|
- [ ] 套餐明细中的当前套餐到期时间继续保留用于解释当前周期,但不得与预计最终到期并列为两个资产汇总口径。(前端任务,不纳入后端交付)
|
||||||
- [ ] `is_expiring=true` 时仅应用约定临期颜色;本票不增加 UR#33 的续费入口、临期列表、通知或其他临期业务行为。(前端任务,不纳入后端交付)
|
- [ ] `is_expiring=true` 时仅应用约定临期颜色;本票不增加 UR#33 的续费入口、临期列表、通知或其他临期业务行为。(前端任务,不纳入后端交付)
|
||||||
- [ ] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成 C 端卡、设备集成测试,覆盖四种状态、nullable 字段、后台一致性以及套餐新增、退款或失效后下一次查询实时变化。(后端验收测试待补)
|
- [x] 使用真实开发 PostgreSQL、Redis、JWT 和进程内 Fiber App 完成 C 端卡、设备集成测试,覆盖四种状态、nullable 字段、后台一致性以及套餐新增、退款或失效后下一次查询实时变化。
|
||||||
- [ ] 前端验收确认 C 端不叠加套餐时长、不自行计算剩余天数,并保持现有套餐明细信息可见。(前端任务,不纳入后端交付)
|
- [ ] 前端验收确认 C 端不叠加套餐时长、不自行计算剩余天数,并保持现有套餐明细信息可见。(前端任务,不纳入后端交付)
|
||||||
|
|||||||
@@ -2,213 +2,415 @@ package packageexpiry
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"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/internal/testutil"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCalculate(t *testing.T) {
|
const (
|
||||||
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
// testIndexCardID 是索引执行计划测试使用的卡资产 ID。
|
||||||
now := time.Date(2026, 7, 23, 12, 0, 0, 0, location)
|
testIndexCardID = 970001
|
||||||
currentExpiry := time.Date(2026, 7, 31, 23, 59, 59, 0, location)
|
// testIndexDeviceID 是索引执行计划测试使用的设备资产 ID。
|
||||||
createdAt := time.Date(2026, 7, 1, 12, 0, 0, 0, location)
|
testIndexDeviceID = 970002
|
||||||
newUsage := func(id uint, status int, priority int) *model.PackageUsage {
|
// testQueryPerformanceLimitMS 是项目数据库查询耗时上限。
|
||||||
return &model.PackageUsage{
|
testQueryPerformanceLimitMS = 50
|
||||||
Model: modelBase(id, createdAt.Add(time.Duration(id)*time.Second)),
|
)
|
||||||
PackageID: id,
|
|
||||||
Status: status,
|
func TestCalculateBasicStatuses(t *testing.T) {
|
||||||
Priority: priority,
|
location, now, currentExpiry := calculationTimes()
|
||||||
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation,
|
assertCalculation(t, nil, nil, now, constants.PackageExpiryEstimateStatusNone, nil, nil)
|
||||||
CalendarTypeSnapshot: constants.PackageCalendarTypeByDay,
|
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||||
DurationDaysSnapshot: 3,
|
current.ExpiresAt = ¤tExpiry
|
||||||
|
assertCalculation(t, []*model.PackageUsage{current}, nil, now, constants.PackageExpiryEstimateStatusExact, ¤tExpiry, intPointer(8))
|
||||||
|
waiting := newCalculationUsage(2, constants.PackageUsageStatusPending, 1)
|
||||||
|
waiting.PendingRealnameActivation = true
|
||||||
|
assertCalculation(t, []*model.PackageUsage{waiting}, nil, now, constants.PackageExpiryEstimateStatusWaitingActivation, nil, nil)
|
||||||
|
expiredAt := time.Date(2026, 7, 20, 23, 59, 59, 0, location)
|
||||||
|
expiredCurrent := newCalculationUsage(3, constants.PackageUsageStatusActive, 1)
|
||||||
|
expiredCurrent.ExpiresAt = &expiredAt
|
||||||
|
assertCalculation(t, []*model.PackageUsage{expiredCurrent}, nil, now, constants.PackageExpiryEstimateStatusExact, &expiredAt, intPointer(-3))
|
||||||
|
duplicateCurrent := newCalculationUsage(4, constants.PackageUsageStatusDepleted, 2)
|
||||||
|
duplicateCurrent.ExpiresAt = ¤tExpiry
|
||||||
|
assertCalculation(t, []*model.PackageUsage{current, duplicateCurrent}, nil, now, constants.PackageExpiryEstimateStatusInvalidData, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateQueueAndCalendarBoundaries(t *testing.T) {
|
||||||
|
location, now, currentExpiry := calculationTimes()
|
||||||
|
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||||
|
current.ExpiresAt = ¤tExpiry
|
||||||
|
firstQueued := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||||
|
secondQueued := newCalculationUsage(3, constants.PackageUsageStatusPending, 3)
|
||||||
|
assertCalculation(t, []*model.PackageUsage{current, firstQueued, secondQueued}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 8, 8, 23, 59, 59, 0, location)), intPointer(16))
|
||||||
|
naturalMonth := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||||
|
naturalMonth.CalendarTypeSnapshot, naturalMonth.DurationDaysSnapshot, naturalMonth.DurationMonthsSnapshot = constants.PackageCalendarTypeNaturalMonth, 0, 1
|
||||||
|
assertCalculation(t, []*model.PackageUsage{current, naturalMonth}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 9, 30, 23, 59, 59, 0, location)), intPointer(69))
|
||||||
|
crossYearExpiry := time.Date(2026, 12, 31, 23, 59, 59, 0, location)
|
||||||
|
crossYearCurrent := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||||
|
crossYearCurrent.ExpiresAt = &crossYearExpiry
|
||||||
|
oneDay := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||||
|
oneDay.DurationDaysSnapshot = 1
|
||||||
|
assertCalculation(t, []*model.PackageUsage{crossYearCurrent, oneDay}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2027, 1, 2, 23, 59, 59, 0, location)), nil)
|
||||||
|
assertDuplicatePriorityOrder(t, location, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateFallbackAndExclusions(t *testing.T) {
|
||||||
|
location, now, currentExpiry := calculationTimes()
|
||||||
|
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||||
|
current.ExpiresAt = ¤tExpiry
|
||||||
|
historical := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||||
|
historical.ExpiryBaseSnapshot, historical.CalendarTypeSnapshot, historical.DurationDaysSnapshot = "", "", 0
|
||||||
|
packages := map[uint]*model.Package{2: {ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 3}}
|
||||||
|
assertCalculation(t, []*model.PackageUsage{current, historical}, packages, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 8, 4, 23, 59, 59, 0, location)), intPointer(12))
|
||||||
|
invalid := newCalculationUsage(3, constants.PackageUsageStatusPending, 2)
|
||||||
|
invalid.CalendarTypeSnapshot = "bad"
|
||||||
|
assertCalculation(t, []*model.PackageUsage{current, invalid}, nil, now, constants.PackageExpiryEstimateStatusInvalidData, nil, nil)
|
||||||
|
assertExcludedUsages(t, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateExpiringBoundaries 验证临期标记包含 0 和 15 天,并排除 16 天及已过期结果。
|
||||||
|
func TestCalculateExpiringBoundaries(t *testing.T) {
|
||||||
|
location, now, _ := calculationTimes()
|
||||||
|
for _, testCase := range []struct {
|
||||||
|
days int
|
||||||
|
want bool
|
||||||
|
}{{days: -1, want: false}, {days: 0, want: true}, {days: 15, want: true}, {days: 16, want: false}} {
|
||||||
|
usage := newCalculationUsage(uint(testCase.days+2), constants.PackageUsageStatusActive, 1)
|
||||||
|
expiresAt := now.AddDate(0, 0, testCase.days).In(location)
|
||||||
|
usage.ExpiresAt = &expiresAt
|
||||||
|
result := Calculate([]*model.PackageUsage{usage}, nil, now)
|
||||||
|
if result.IsExpiring != testCase.want {
|
||||||
|
t.Fatalf("临期边界错误:days=%d want=%v result=%+v", testCase.days, testCase.want, result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
func calculationTimes() (*time.Location, time.Time, time.Time) {
|
||||||
name string
|
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||||
usages []*model.PackageUsage
|
return location, time.Date(2026, 7, 23, 12, 0, 0, 0, location), time.Date(2026, 7, 31, 23, 59, 59, 0, location)
|
||||||
packages map[uint]*model.Package
|
|
||||||
wantStatus string
|
|
||||||
wantDate *time.Time
|
|
||||||
wantDays *int
|
|
||||||
}{
|
|
||||||
{name: "无套餐", wantStatus: constants.PackageExpiryEstimateStatusNone},
|
|
||||||
{
|
|
||||||
name: "当前套餐", usages: func() []*model.PackageUsage {
|
|
||||||
u := newUsage(1, constants.PackageUsageStatusActive, 1)
|
|
||||||
u.ExpiresAt = ¤tExpiry
|
|
||||||
return []*model.PackageUsage{u}
|
|
||||||
}(), wantStatus: constants.PackageExpiryEstimateStatusExact, wantDate: ¤tExpiry, wantDays: intPointer(8),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "多段队列按下一时刻接续", usages: func() []*model.PackageUsage {
|
|
||||||
current := newUsage(1, constants.PackageUsageStatusActive, 1)
|
|
||||||
current.ExpiresAt = ¤tExpiry
|
|
||||||
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
|
|
||||||
queued.DurationDaysSnapshot = 3
|
|
||||||
return []*model.PackageUsage{current, queued}
|
|
||||||
}(), wantStatus: constants.PackageExpiryEstimateStatusExact,
|
|
||||||
wantDate: timePointer(time.Date(2026, 8, 4, 23, 59, 59, 0, location)), wantDays: intPointer(12),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "自然月月末", usages: func() []*model.PackageUsage {
|
|
||||||
current := newUsage(1, constants.PackageUsageStatusActive, 1)
|
|
||||||
current.ExpiresAt = ¤tExpiry
|
|
||||||
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
|
|
||||||
queued.CalendarTypeSnapshot = constants.PackageCalendarTypeNaturalMonth
|
|
||||||
queued.DurationDaysSnapshot = 0
|
|
||||||
queued.DurationMonthsSnapshot = 1
|
|
||||||
return []*model.PackageUsage{current, queued}
|
|
||||||
}(), wantStatus: constants.PackageExpiryEstimateStatusExact,
|
|
||||||
wantDate: timePointer(time.Date(2026, 9, 30, 23, 59, 59, 0, location)), wantDays: intPointer(69),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "等待实名激活", usages: func() []*model.PackageUsage {
|
|
||||||
u := newUsage(1, constants.PackageUsageStatusPending, 1)
|
|
||||||
u.PendingRealnameActivation = true
|
|
||||||
return []*model.PackageUsage{u}
|
|
||||||
}(), wantStatus: constants.PackageExpiryEstimateStatusWaitingActivation,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "历史空快照回退", usages: func() []*model.PackageUsage {
|
|
||||||
current := newUsage(1, constants.PackageUsageStatusActive, 1)
|
|
||||||
current.ExpiresAt = ¤tExpiry
|
|
||||||
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
|
|
||||||
queued.ExpiryBaseSnapshot, queued.CalendarTypeSnapshot, queued.DurationDaysSnapshot = "", "", 0
|
|
||||||
return []*model.PackageUsage{current, queued}
|
|
||||||
}(), packages: map[uint]*model.Package{2: {ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 3}},
|
|
||||||
wantStatus: constants.PackageExpiryEstimateStatusExact,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "非法快照", usages: func() []*model.PackageUsage {
|
|
||||||
current := newUsage(1, constants.PackageUsageStatusActive, 1)
|
|
||||||
current.ExpiresAt = ¤tExpiry
|
|
||||||
queued := newUsage(2, constants.PackageUsageStatusPending, 2)
|
|
||||||
queued.CalendarTypeSnapshot = "bad"
|
|
||||||
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) {
|
func newCalculationUsage(id uint, status, priority int) *model.PackageUsage {
|
||||||
got := Calculate(tt.usages, tt.packages, now)
|
createdAt := time.Date(2026, 7, 1, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
if got.ExpiryEstimateStatus != tt.wantStatus {
|
return &model.PackageUsage{Model: modelBase(id, createdAt.Add(time.Duration(id)*time.Second)), PackageID: id, Status: status, Priority: priority, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 3}
|
||||||
t.Fatalf("状态错误:want=%s got=%s", tt.wantStatus, got.ExpiryEstimateStatus)
|
|
||||||
}
|
}
|
||||||
if tt.wantDate != nil && (got.EstimatedFinalExpiresAt == nil || !got.EstimatedFinalExpiresAt.Equal(*tt.wantDate)) {
|
|
||||||
t.Fatalf("到期时间错误:want=%v got=%v", tt.wantDate, got.EstimatedFinalExpiresAt)
|
func assertCalculation(t *testing.T, usages []*model.PackageUsage, packages map[uint]*model.Package, now time.Time, wantStatus string, wantDate *time.Time, wantDays *int) {
|
||||||
|
t.Helper()
|
||||||
|
got := Calculate(usages, packages, now)
|
||||||
|
if got.ExpiryEstimateStatus != wantStatus || wantDate != nil && (got.EstimatedFinalExpiresAt == nil || !got.EstimatedFinalExpiresAt.Equal(*wantDate)) || wantDays != nil && (got.DaysUntilFinalExpiry == nil || *got.DaysUntilFinalExpiry != *wantDays) {
|
||||||
|
t.Fatalf("推算结果错误:status=%s date=%v days=%v result=%+v", wantStatus, wantDate, wantDays, got)
|
||||||
}
|
}
|
||||||
if tt.wantDays != nil && (got.DaysUntilFinalExpiry == nil || *got.DaysUntilFinalExpiry != *tt.wantDays) {
|
if wantStatus != constants.PackageExpiryEstimateStatusExact && (got.EstimatedFinalExpiresAt != nil || got.DaysUntilFinalExpiry != nil) {
|
||||||
t.Fatalf("剩余天数错误:want=%v got=%v", *tt.wantDays, got.DaysUntilFinalExpiry)
|
|
||||||
}
|
|
||||||
if tt.wantStatus != constants.PackageExpiryEstimateStatusExact && (got.EstimatedFinalExpiresAt != nil || got.DaysUntilFinalExpiry != nil) {
|
|
||||||
t.Fatalf("非精确状态必须返回 null:%+v", got)
|
t.Fatalf("非精确状态必须返回 null:%+v", got)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
func assertDuplicatePriorityOrder(t *testing.T, location *time.Location, now time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
expiresAt := time.Date(2026, 1, 30, 23, 59, 59, 0, location)
|
||||||
|
current := newCalculationUsage(1, constants.PackageUsageStatusActive, 1)
|
||||||
|
current.ExpiresAt = &expiresAt
|
||||||
|
naturalMonth := newCalculationUsage(2, constants.PackageUsageStatusPending, 2)
|
||||||
|
naturalMonth.CalendarTypeSnapshot, naturalMonth.DurationDaysSnapshot, naturalMonth.DurationMonthsSnapshot = constants.PackageCalendarTypeNaturalMonth, 0, 1
|
||||||
|
byDay := newCalculationUsage(3, constants.PackageUsageStatusPending, 2)
|
||||||
|
byDay.DurationDaysSnapshot = 5
|
||||||
|
assertCalculation(t, []*model.PackageUsage{byDay, current, naturalMonth}, nil, now, constants.PackageExpiryEstimateStatusExact, timePointer(time.Date(2026, 3, 6, 23, 59, 59, 0, location)), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertExcludedUsages(t *testing.T, now time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
expired := newCalculationUsage(1, constants.PackageUsageStatusExpired, 1)
|
||||||
|
invalidated := newCalculationUsage(2, constants.PackageUsageStatusInvalidated, 2)
|
||||||
|
refundID, masterID := uint(3), uint(1)
|
||||||
|
refunded := newCalculationUsage(3, constants.PackageUsageStatusActive, 3)
|
||||||
|
refunded.RefundID = &refundID
|
||||||
|
deleted := newCalculationUsage(4, constants.PackageUsageStatusActive, 4)
|
||||||
|
deleted.DeletedAt = gorm.DeletedAt{Time: now, Valid: true}
|
||||||
|
addon := newCalculationUsage(5, constants.PackageUsageStatusActive, 5)
|
||||||
|
addon.MasterUsageID = &masterID
|
||||||
|
assertCalculation(t, []*model.PackageUsage{expired, invalidated, refunded, deleted, addon}, nil, now, constants.PackageExpiryEstimateStatusNone, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalculateUsesShanghaiCalendarDays 验证剩余天数按上海自然日而非不足 24 小时取整。
|
||||||
|
func TestCalculateUsesShanghaiCalendarDays(t *testing.T) {
|
||||||
|
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||||
|
now := time.Date(2026, 7, 23, 23, 30, 0, 0, location)
|
||||||
|
expiresAt := time.Date(2026, 7, 24, 0, 30, 0, 0, location)
|
||||||
|
usage := &model.PackageUsage{Model: modelBase(1, now), Status: constants.PackageUsageStatusActive, Priority: 1, ExpiresAt: &expiresAt}
|
||||||
|
result := Calculate([]*model.PackageUsage{usage}, nil, now)
|
||||||
|
if result.DaysUntilFinalExpiry == nil || *result.DaysUntilFinalExpiry != 1 {
|
||||||
|
t.Fatalf("上海自然日差错误:want=1 got=%v", result.DaysUntilFinalExpiry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestQueryResolveBatchUsesOneBatchLoad 验证一页资产只通过批量读取计算,不逐卡查询套餐。
|
// TestQueryResolveBatchUsesOneBatchLoad 验证卡和设备各 100 个资产只执行一次套餐读取。
|
||||||
func TestQueryResolveBatchUsesOneBatchLoad(t *testing.T) {
|
func TestQueryResolveBatchUsesOneBatchLoad(t *testing.T) {
|
||||||
|
for _, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
|
||||||
|
t.Run(assetType, func(t *testing.T) {
|
||||||
tx := testutil.NewPostgresTransaction(t)
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
now := time.Date(2026, 7, 23, 12, 0, 0, 0, location)
|
|
||||||
assetIDs := make([]uint, 0, 100)
|
assetIDs := make([]uint, 0, 100)
|
||||||
for i := 1; i <= 100; i++ {
|
for i := 1; i <= 100; i++ {
|
||||||
assetID := uint(970000 + i)
|
assetID := uint(970000 + i)
|
||||||
assetIDs = append(assetIDs, assetID)
|
assetIDs = append(assetIDs, assetID)
|
||||||
expiresAt := now.AddDate(0, 0, i%16)
|
expiresAt := now.AddDate(0, 0, i%16)
|
||||||
usage := &model.PackageUsage{
|
usage := newQueryConsistencyUsage(assetType, assetID, constants.PackageUsageStatusActive, 1, now)
|
||||||
OrderID: assetID,
|
usage.ExpiresAt = &expiresAt
|
||||||
OrderNo: "UR46-BATCH",
|
usage.OrderNo = "UR46-BATCH"
|
||||||
PackageID: assetID,
|
createQueryConsistencyUsage(t, tx, usage)
|
||||||
UsageType: constants.AssetTypeIotCard,
|
|
||||||
IotCardID: assetID,
|
|
||||||
DataLimitMB: 1,
|
|
||||||
Status: constants.PackageUsageStatusActive,
|
|
||||||
Priority: 1,
|
|
||||||
ActivatedAt: &now,
|
|
||||||
ExpiresAt: &expiresAt,
|
|
||||||
ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation,
|
|
||||||
CalendarTypeSnapshot: constants.PackageCalendarTypeByDay,
|
|
||||||
DurationDaysSnapshot: 30,
|
|
||||||
DurationMonthsSnapshot: 0,
|
|
||||||
}
|
}
|
||||||
if err := tx.Create(usage).Error; err != nil {
|
counter := &queryCounterLogger{Interface: tx.Logger}
|
||||||
t.Fatalf("创建批量套餐使用记录失败:%v", err)
|
query := NewQuery(tx.Session(&gorm.Session{Logger: counter}))
|
||||||
}
|
|
||||||
}
|
|
||||||
query := NewQuery(tx)
|
|
||||||
query.now = func() time.Time { return now }
|
query.now = func() time.Time { return now }
|
||||||
results, err := query.ResolveBatch(context.Background(), constants.AssetTypeIotCard, assetIDs)
|
results, err := query.ResolveBatch(context.Background(), assetType, assetIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("批量查询失败:%v", err)
|
t.Fatalf("批量查询失败:%v", err)
|
||||||
}
|
}
|
||||||
if len(results) != len(assetIDs) {
|
if len(results) != len(assetIDs) || counter.count != 1 {
|
||||||
t.Fatalf("批量结果数量错误:want=%d got=%d", len(assetIDs), len(results))
|
t.Fatalf("100 个资产批量读取错误:results=%d queries=%d", len(results), counter.count)
|
||||||
}
|
}
|
||||||
for _, assetID := range assetIDs {
|
for _, assetID := range assetIDs {
|
||||||
if results[assetID].ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact {
|
if results[assetID].ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact {
|
||||||
t.Fatalf("资产 %d 应得到精确结果:%+v", assetID, results[assetID])
|
t.Fatalf("资产 %d 应得到精确结果:%+v", assetID, results[assetID])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestQueryIndexesExplainAnalyze 验证开发 PostgreSQL 为两类资产队列查询选择 UR46 部分索引。
|
type queryCounterLogger struct {
|
||||||
|
logger.Interface
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *queryCounterLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||||
|
l.count++
|
||||||
|
l.Interface.Trace(ctx, begin, fc, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryResolveAndResolveBatchReturnSameResult 验证单资产与批量入口共享同一结果口径。
|
||||||
|
func TestQueryResolveAndResolveBatchReturnSameResult(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
assetType string
|
||||||
|
scenario string
|
||||||
|
}{
|
||||||
|
{name: "卡历史回退", assetType: constants.AssetTypeIotCard, scenario: "historical"},
|
||||||
|
{name: "卡等待激活", assetType: constants.AssetTypeIotCard, scenario: "waiting"},
|
||||||
|
{name: "卡异常数据", assetType: constants.AssetTypeIotCard, scenario: "invalid"},
|
||||||
|
{name: "卡负数天数", assetType: constants.AssetTypeIotCard, scenario: "expired"},
|
||||||
|
{name: "设备历史回退", assetType: constants.AssetTypeDevice, scenario: "historical"},
|
||||||
|
{name: "设备等待激活", assetType: constants.AssetTypeDevice, scenario: "waiting"},
|
||||||
|
{name: "设备异常数据", assetType: constants.AssetTypeDevice, scenario: "invalid"},
|
||||||
|
{name: "设备负数天数", assetType: constants.AssetTypeDevice, scenario: "expired"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
|
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
|
assetID := uint(980001)
|
||||||
|
createQueryConsistencyFixture(t, tx, tt.assetType, assetID, tt.scenario, now)
|
||||||
|
query := NewQuery(tx)
|
||||||
|
query.now = func() time.Time { return now }
|
||||||
|
single, err := query.Resolve(context.Background(), tt.assetType, assetID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("单资产查询失败:%v", err)
|
||||||
|
}
|
||||||
|
batch, err := query.ResolveBatch(context.Background(), tt.assetType, []uint{assetID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("批量查询失败:%v", err)
|
||||||
|
}
|
||||||
|
assertEstimateEqual(t, single, batch[assetID])
|
||||||
|
assertScenarioEstimate(t, single, tt.scenario, now)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertScenarioEstimate 验证一致性夹具不仅两入口相同,也符合各场景业务结果。
|
||||||
|
func assertScenarioEstimate(t *testing.T, estimate dto.PackageExpiryEstimate, scenario string, now time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
switch scenario {
|
||||||
|
case "historical":
|
||||||
|
currentExpiry := now.AddDate(0, 0, 5)
|
||||||
|
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.EstimatedFinalExpiresAt == nil || !estimate.EstimatedFinalExpiresAt.After(currentExpiry) {
|
||||||
|
t.Fatalf("历史快照回退未参与套餐接续:%+v", estimate)
|
||||||
|
}
|
||||||
|
case "waiting":
|
||||||
|
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusWaitingActivation {
|
||||||
|
t.Fatalf("等待激活场景错误:%+v", estimate)
|
||||||
|
}
|
||||||
|
case "invalid":
|
||||||
|
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusInvalidData {
|
||||||
|
t.Fatalf("异常数据场景错误:%+v", estimate)
|
||||||
|
}
|
||||||
|
case "expired":
|
||||||
|
if estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.DaysUntilFinalExpiry == nil || *estimate.DaysUntilFinalExpiry != -3 {
|
||||||
|
t.Fatalf("负数天数场景错误:%+v", estimate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createQueryConsistencyFixture 按场景创建单资产与批量入口共享的数据库夹具。
|
||||||
|
func createQueryConsistencyFixture(t *testing.T, tx *gorm.DB, assetType string, assetID uint, scenario string, now time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
usage := newQueryConsistencyUsage(assetType, assetID, constants.PackageUsageStatusActive, 1, now)
|
||||||
|
switch scenario {
|
||||||
|
case "waiting":
|
||||||
|
usage.Status = constants.PackageUsageStatusPending
|
||||||
|
usage.PendingRealnameActivation = true
|
||||||
|
usage.ExpiresAt = nil
|
||||||
|
createQueryConsistencyUsage(t, tx, usage)
|
||||||
|
case "invalid":
|
||||||
|
usage.ExpiresAt = nil
|
||||||
|
createQueryConsistencyUsage(t, tx, usage)
|
||||||
|
case "expired":
|
||||||
|
expiresAt := now.AddDate(0, 0, -3)
|
||||||
|
usage.ExpiresAt = &expiresAt
|
||||||
|
createQueryConsistencyUsage(t, tx, usage)
|
||||||
|
case "historical":
|
||||||
|
expiresAt := now.AddDate(0, 0, 5)
|
||||||
|
usage.ExpiresAt = &expiresAt
|
||||||
|
createQueryConsistencyUsage(t, tx, usage)
|
||||||
|
pkg := &model.Package{PackageCode: "UR46-FALLBACK-" + assetType, PackageName: "UR46历史回退套餐", PackageType: constants.PackageTypeFormal, DurationDays: 3, CalendarType: constants.PackageCalendarTypeByDay, ExpiryBase: constants.PackageExpiryBaseFromActivation, Status: constants.StatusEnabled, ShelfStatus: 1}
|
||||||
|
if err := tx.Create(pkg).Error; err != nil {
|
||||||
|
t.Fatalf("创建历史回退套餐失败:%v", err)
|
||||||
|
}
|
||||||
|
queued := newQueryConsistencyUsage(assetType, assetID, constants.PackageUsageStatusPending, 2, now)
|
||||||
|
queued.PackageID = pkg.ID
|
||||||
|
queued.OrderID++
|
||||||
|
queued.OrderNo = "UR46-CONSISTENCY-Q"
|
||||||
|
queued.ExpiryBaseSnapshot = ""
|
||||||
|
queued.CalendarTypeSnapshot = ""
|
||||||
|
queued.DurationDaysSnapshot = 0
|
||||||
|
// 历史套餐允许缺少快照;仅在测试事务内临时关闭新数据校验触发器,事务结束后状态自动恢复。
|
||||||
|
if err := tx.Exec("ALTER TABLE tb_package_usage DISABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||||
|
t.Fatalf("禁用历史快照触发器失败:%v", err)
|
||||||
|
}
|
||||||
|
createQueryConsistencyUsage(t, tx, queued)
|
||||||
|
if err := tx.Exec("ALTER TABLE tb_package_usage ENABLE TRIGGER trg_validate_package_usage_terms_snapshot").Error; err != nil {
|
||||||
|
t.Fatalf("恢复历史快照触发器失败:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newQueryConsistencyUsage(assetType string, assetID uint, status, priority int, now time.Time) *model.PackageUsage {
|
||||||
|
usage := &model.PackageUsage{OrderID: assetID, OrderNo: "UR46-CONSISTENCY", PackageID: assetID, UsageType: assetType, DataLimitMB: 1, Status: status, Priority: priority, ActivatedAt: &now, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
|
||||||
|
if assetType == constants.AssetTypeDevice {
|
||||||
|
usage.DeviceID = assetID
|
||||||
|
} else {
|
||||||
|
usage.IotCardID = assetID
|
||||||
|
}
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func createQueryConsistencyUsage(t *testing.T, tx *gorm.DB, usage *model.PackageUsage) {
|
||||||
|
t.Helper()
|
||||||
|
desiredStatus := usage.Status
|
||||||
|
pendingRealname := usage.PendingRealnameActivation
|
||||||
|
if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
||||||
|
t.Fatalf("创建一致性套餐记录失败:%v", err)
|
||||||
|
}
|
||||||
|
if err := tx.Model(usage).Updates(map[string]any{"status": desiredStatus, "pending_realname_activation": pendingRealname}).Error; err != nil {
|
||||||
|
t.Fatalf("更新一致性套餐状态失败:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertEstimateEqual(t *testing.T, left, right dto.PackageExpiryEstimate) {
|
||||||
|
t.Helper()
|
||||||
|
if left.ExpiryEstimateStatus != right.ExpiryEstimateStatus || left.ExpiryEstimateStatusName != right.ExpiryEstimateStatusName || left.IsExpiring != right.IsExpiring || !timePointersEqual(left.EstimatedFinalExpiresAt, right.EstimatedFinalExpiresAt) || !intPointersEqual(left.DaysUntilFinalExpiry, right.DaysUntilFinalExpiry) {
|
||||||
|
t.Fatalf("单资产与批量结果不一致:single=%+v batch=%+v", left, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func timePointersEqual(left, right *time.Time) bool {
|
||||||
|
return left == nil && right == nil || left != nil && right != nil && left.Equal(*right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPointersEqual(left, right *int) bool {
|
||||||
|
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueryIndexesExplainAnalyze 验证 UR46 部分索引定义及代表性查询执行时间。
|
||||||
func TestQueryIndexesExplainAnalyze(t *testing.T) {
|
func TestQueryIndexesExplainAnalyze(t *testing.T) {
|
||||||
tx := testutil.NewPostgresTransaction(t)
|
tx := testutil.NewPostgresTransaction(t)
|
||||||
queries := []string{
|
createIndexPlanFixtures(t, tx)
|
||||||
"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",
|
var indexDefinitions []string
|
||||||
"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",
|
if err := tx.Raw("SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND indexname IN (?, ?) ORDER BY indexname", "idx_package_usage_expiry_iot_card_queue", "idx_package_usage_expiry_device_queue").Scan(&indexDefinitions).Error; err != nil {
|
||||||
|
t.Fatalf("查询 UR46 索引定义失败:%v", err)
|
||||||
|
}
|
||||||
|
if len(indexDefinitions) != 2 {
|
||||||
|
t.Fatalf("UR46 部分索引数量错误:want=2 got=%d", len(indexDefinitions))
|
||||||
|
}
|
||||||
|
for _, definition := range indexDefinitions {
|
||||||
|
if !strings.Contains(definition, "master_usage_id IS NULL") || !strings.Contains(definition, "refund_id IS NULL") || !strings.Contains(definition, "status = ANY") {
|
||||||
|
t.Fatalf("UR46 部分索引条件不完整:%s", definition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Exec("ANALYZE tb_package_usage").Error; err != nil {
|
||||||
|
t.Fatalf("更新代表性数据统计信息失败:%v", err)
|
||||||
|
}
|
||||||
|
queries := []struct {
|
||||||
|
sql string
|
||||||
|
indexName string
|
||||||
|
}{
|
||||||
|
{sql: "EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE deleted_at IS NULL AND 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", indexName: "idx_package_usage_expiry_iot_card_queue"},
|
||||||
|
{sql: "EXPLAIN ANALYZE SELECT id FROM tb_package_usage WHERE deleted_at IS NULL AND device_id = 970002 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", indexName: "idx_package_usage_expiry_device_queue"},
|
||||||
}
|
}
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
var lines []string
|
var lines []string
|
||||||
if err := tx.Raw(query).Scan(&lines).Error; err != nil {
|
if err := tx.Raw(query.sql).Scan(&lines).Error; err != nil {
|
||||||
t.Fatalf("执行 EXPLAIN ANALYZE 失败:%v", err)
|
t.Fatalf("执行 EXPLAIN ANALYZE 失败:%v", err)
|
||||||
}
|
}
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
t.Fatal("EXPLAIN ANALYZE 未返回执行计划")
|
t.Fatal("EXPLAIN ANALYZE 未返回执行计划")
|
||||||
}
|
}
|
||||||
|
plan := strings.Join(lines, "\n")
|
||||||
|
if !strings.Contains(plan, query.indexName) {
|
||||||
|
t.Fatalf("代表性查询未命中 UR46 部分索引 %s:%s", query.indexName, plan)
|
||||||
|
}
|
||||||
|
match := regexp.MustCompile(`Execution Time: ([0-9.]+) ms`).FindStringSubmatch(plan)
|
||||||
|
if len(match) != 2 {
|
||||||
|
t.Fatalf("执行计划缺少执行时间:%v", lines)
|
||||||
|
}
|
||||||
|
executionMS, err := strconv.ParseFloat(match[1], 64)
|
||||||
|
if err != nil || executionMS >= testQueryPerformanceLimitMS {
|
||||||
|
t.Fatalf("代表性套餐队列查询超过 50ms:execution_ms=%v err=%v", executionMS, err)
|
||||||
|
}
|
||||||
t.Logf("%s", lines)
|
t.Logf("%s", lines)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createIndexPlanFixtures 创建高基数背景数据和目标套餐队列,供正常规划器评估索引选择。
|
||||||
|
func createIndexPlanFixtures(t *testing.T, tx *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
|
expiresAt := now.AddDate(0, 0, 30)
|
||||||
|
usages := make([]model.PackageUsage, 0, 2020)
|
||||||
|
for index := 0; index < 1010; index++ {
|
||||||
|
cardID, deviceID := uint(971000+index), uint(972500+index)
|
||||||
|
if index < 10 {
|
||||||
|
cardID, deviceID = testIndexCardID, testIndexDeviceID
|
||||||
|
}
|
||||||
|
card := newQueryConsistencyUsage(constants.AssetTypeIotCard, cardID, constants.PackageUsageStatusActive, index+1, now)
|
||||||
|
card.OrderID, card.PackageID, card.OrderNo, card.ExpiresAt = uint(800000+index), uint(800000+index), "UR46-INDEX-CARD-"+strconv.Itoa(index), &expiresAt
|
||||||
|
device := newQueryConsistencyUsage(constants.AssetTypeDevice, deviceID, constants.PackageUsageStatusActive, index+1, now)
|
||||||
|
device.OrderID, device.PackageID, device.OrderNo, device.ExpiresAt = uint(900000+index), uint(900000+index), "UR46-INDEX-DEVICE-"+strconv.Itoa(index), &expiresAt
|
||||||
|
usages = append(usages, *card, *device)
|
||||||
|
}
|
||||||
|
if err := tx.CreateInBatches(usages, 200).Error; err != nil {
|
||||||
|
t.Fatalf("创建索引代表性数据失败:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func modelBase(id uint, createdAt time.Time) gorm.Model {
|
func modelBase(id uint, createdAt time.Time) gorm.Model {
|
||||||
return gorm.Model{ID: id, CreatedAt: createdAt}
|
return gorm.Model{ID: id, CreatedAt: createdAt}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
@@ -37,6 +38,13 @@ import (
|
|||||||
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
|
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// testListPageSize 是列表批量验收的代表性每页资产数量。
|
||||||
|
testListPageSize = 100
|
||||||
|
// testHTTPPerformanceLimit 是单次集成请求可验证的项目 API P99 响应耗时上限。
|
||||||
|
testHTTPPerformanceLimit = 500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
// TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace 验证真实认证、权限、资产解析和双向换货投影。
|
// TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace 验证真实认证、权限、资产解析和双向换货投影。
|
||||||
func TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace(t *testing.T) {
|
func TestAssetResolveHTTPIntegratesAuthenticationPermissionsAndExchangeTrace(t *testing.T) {
|
||||||
env := newAssetTraceHTTPEnv(t)
|
env := newAssetTraceHTTPEnv(t)
|
||||||
@@ -125,9 +133,10 @@ func TestAssetResolveHTTPReturnsPackageExpiryEstimate(t *testing.T) {
|
|||||||
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
expiresAt := now.AddDate(0, 0, 5)
|
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}
|
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 {
|
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
|
||||||
t.Fatalf("创建详情套餐使用记录失败:%v", err)
|
t.Fatalf("创建详情套餐使用记录失败:%v", err)
|
||||||
}
|
}
|
||||||
|
assertPackageUsagePersisted(t, env.db, usage.ID, constants.PackageUsageStatusActive)
|
||||||
status, body := env.request(t, "/api/admin/assets/resolve/"+card.ICCID, env.newAgentToken(t, shop.ID))
|
status, body := env.request(t, "/api/admin/assets/resolve/"+card.ICCID, env.newAgentToken(t, shop.ID))
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("查询资产详情失败:%d %s", status, body)
|
t.Fatalf("查询资产详情失败:%d %s", status, body)
|
||||||
@@ -147,43 +156,369 @@ func TestAssetResolveHTTPReturnsPackageExpiryEstimate(t *testing.T) {
|
|||||||
if response.Data.ExpiresAt == nil || response.Data.Days == nil || response.Data.Status != constants.PackageExpiryEstimateStatusExact || response.Data.Name == "" || !response.Data.Expiring {
|
if response.Data.ExpiresAt == nil || response.Data.Days == nil || response.Data.Status != constants.PackageExpiryEstimateStatusExact || response.Data.Name == "" || !response.Data.Expiring {
|
||||||
t.Fatalf("预计到期字段不完整:%s", body)
|
t.Fatalf("预计到期字段不完整:%s", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
device := env.createDevice(t, 46, &shop.ID)
|
||||||
|
deviceUsage := newExpiryHTTPUsage(device.ID, constants.AssetTypeDevice, constants.PackageUsageStatusActive, &expiresAt, false, now)
|
||||||
|
if err := createExpiryHTTPUsage(env.db, deviceUsage); err != nil {
|
||||||
|
t.Fatalf("创建设备详情套餐使用记录失败:%v", err)
|
||||||
|
}
|
||||||
|
assertPackageUsagePersisted(t, env.db, deviceUsage.ID, constants.PackageUsageStatusActive)
|
||||||
|
status, body = env.request(t, "/api/admin/assets/resolve/"+device.VirtualNo, env.newAgentToken(t, shop.ID))
|
||||||
|
assertExpiryHTTPStatus(t, status, body, constants.PackageExpiryEstimateStatusExact, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClientAssetInfoHTTPReturnsPackageExpiryEstimate 验证 C 端经真实 JWT、Redis 和 Fiber 返回与后台一致的预计到期字段。
|
// TestAssetResolveHTTPReturnsAllPackageExpiryStatuses 验证后台详情完整返回四种状态及 nullable 语义。
|
||||||
func TestClientAssetInfoHTTPReturnsPackageExpiryEstimate(t *testing.T) {
|
func TestAssetResolveHTTPReturnsAllPackageExpiryStatuses(t *testing.T) {
|
||||||
env := newAssetTraceHTTPEnv(t)
|
env := newAssetTraceHTTPEnv(t)
|
||||||
card := env.createCard(t, 47, nil)
|
shop := env.createShop(t, "UR46四状态店铺", nil, 1)
|
||||||
|
token := env.newAgentToken(t, shop.ID)
|
||||||
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
expiresAt := now.AddDate(0, 0, 5)
|
for typeIndex, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
|
||||||
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}
|
for statusIndex, estimateStatus := range []string{constants.PackageExpiryEstimateStatusNone, constants.PackageExpiryEstimateStatusWaitingActivation, constants.PackageExpiryEstimateStatusInvalidData} {
|
||||||
if err := env.db.Create(usage).Error; err != nil {
|
name := assetType + "/" + estimateStatus
|
||||||
t.Fatalf("创建 C 端套餐使用记录失败:%v", err)
|
t.Run(name, func(t *testing.T) {
|
||||||
|
asset := createAdminExpiryTestAsset(t, env, assetType, 60+typeIndex*10+statusIndex, &shop.ID)
|
||||||
|
if estimateStatus != constants.PackageExpiryEstimateStatusNone {
|
||||||
|
usageStatus, waiting := constants.PackageUsageStatusActive, false
|
||||||
|
if estimateStatus == constants.PackageExpiryEstimateStatusWaitingActivation {
|
||||||
|
usageStatus, waiting = constants.PackageUsageStatusPending, true
|
||||||
}
|
}
|
||||||
customer := &model.PersonalCustomer{WxOpenID: "ur46-client-openid-" + strconv.FormatInt(time.Now().UnixNano(), 10), WxUnionID: "ur46-client-union", Status: constants.StatusEnabled}
|
usage := newExpiryHTTPUsage(asset.assetID, assetType, usageStatus, nil, waiting, now)
|
||||||
|
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
|
||||||
|
t.Fatalf("创建状态套餐失败:%v", err)
|
||||||
|
}
|
||||||
|
assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
|
||||||
|
}
|
||||||
|
status, body := env.request(t, "/api/admin/assets/resolve/"+asset.identifier, token)
|
||||||
|
assertExpiryHTTPStatus(t, status, body, estimateStatus, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAdminExpiryTestAsset(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int, shopID *uint) clientExpiryTestAsset {
|
||||||
|
t.Helper()
|
||||||
|
if assetType == constants.AssetTypeDevice {
|
||||||
|
device := env.createDevice(t, suffix, shopID)
|
||||||
|
return clientExpiryTestAsset{assetType: assetType, assetID: device.ID, virtualNo: device.VirtualNo, identifier: device.VirtualNo}
|
||||||
|
}
|
||||||
|
card := env.createCard(t, suffix, shopID)
|
||||||
|
return clientExpiryTestAsset{assetType: assetType, assetID: card.ID, virtualNo: card.VirtualNo, identifier: card.ICCID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAssetResolveHTTPReflectsQueueChangesImmediately 验证新增、退款和失效队列后无需刷新资产快照。
|
||||||
|
func TestAssetResolveHTTPReflectsQueueChangesImmediately(t *testing.T) {
|
||||||
|
env := newAssetTraceHTTPEnv(t)
|
||||||
|
shop := env.createShop(t, "UR46实时变化店铺", nil, 1)
|
||||||
|
for index, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
|
||||||
|
t.Run(assetType, func(t *testing.T) {
|
||||||
|
asset := createAdminExpiryTestAsset(t, env, assetType, 63+index, &shop.ID)
|
||||||
|
verifyAdminQueueChanges(t, env, asset, shop.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyAdminQueueChanges 验证后台详情实时反映指定资产的套餐队列变化。
|
||||||
|
func verifyAdminQueueChanges(t *testing.T, env *assetTraceHTTPEnv, asset clientExpiryTestAsset, shopID uint) {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
|
currentExpiry := now.AddDate(0, 0, 5)
|
||||||
|
current := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusActive, ¤tExpiry, false, now)
|
||||||
|
if err := createExpiryHTTPUsage(env.db, current); err != nil {
|
||||||
|
t.Fatalf("创建当前套餐失败:%v", err)
|
||||||
|
}
|
||||||
|
assertPackageUsagePersisted(t, env.db, current.ID, constants.PackageUsageStatusActive)
|
||||||
|
token := env.newAgentToken(t, shopID)
|
||||||
|
initial := requestExpiryEstimate(t, env, asset.identifier, token)
|
||||||
|
queued := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusPending, nil, false, now)
|
||||||
|
queued.Priority = 2
|
||||||
|
queued.PackageID = asset.assetID + 1
|
||||||
|
queued.OrderNo = fmt.Sprintf("UR46-Q-%s-%d", asset.assetType, asset.assetID)
|
||||||
|
if err := createExpiryHTTPUsage(env.db, queued); err != nil {
|
||||||
|
t.Fatalf("创建排队套餐失败:%v", err)
|
||||||
|
}
|
||||||
|
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusPending)
|
||||||
|
extended := requestExpiryEstimate(t, env, asset.identifier, token)
|
||||||
|
if initial.ExpiresAt == nil || extended.ExpiresAt == nil || !extended.ExpiresAt.After(*initial.ExpiresAt) {
|
||||||
|
t.Fatalf("新增排队套餐后预计到期未延长:initial=%v extended=%v", initial.ExpiresAt, extended.ExpiresAt)
|
||||||
|
}
|
||||||
|
refundID := uint(990001)
|
||||||
|
if err := env.db.Model(queued).Update("refund_id", refundID).Error; err != nil {
|
||||||
|
t.Fatalf("设置排队套餐退款状态失败:%v", err)
|
||||||
|
}
|
||||||
|
assertPackageUsageRefunded(t, env.db, queued.ID, refundID)
|
||||||
|
refunded := requestExpiryEstimate(t, env, asset.identifier, token)
|
||||||
|
if refunded.ExpiresAt == nil || !refunded.ExpiresAt.Equal(*initial.ExpiresAt) {
|
||||||
|
t.Fatalf("退款后预计到期未实时恢复:initial=%v refunded=%v", initial.ExpiresAt, refunded.ExpiresAt)
|
||||||
|
}
|
||||||
|
if err := env.db.Model(queued).Updates(map[string]any{"refund_id": nil, "status": constants.PackageUsageStatusInvalidated}).Error; err != nil {
|
||||||
|
t.Fatalf("设置排队套餐失效状态失败:%v", err)
|
||||||
|
}
|
||||||
|
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusInvalidated)
|
||||||
|
invalidated := requestExpiryEstimate(t, env, asset.identifier, token)
|
||||||
|
if invalidated.ExpiresAt == nil || !invalidated.ExpiresAt.Equal(*initial.ExpiresAt) {
|
||||||
|
t.Fatalf("失效后预计到期未实时恢复:initial=%v invalidated=%v", initial.ExpiresAt, invalidated.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type expiryHTTPResult 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestExpiryEstimate 请求后台统一资产详情并验证预计到期字段的 JSON 契约。
|
||||||
|
func requestExpiryEstimate(t *testing.T, env *assetTraceHTTPEnv, identifier, token string) expiryHTTPResult {
|
||||||
|
t.Helper()
|
||||||
|
status, body := env.request(t, "/api/admin/assets/resolve/"+identifier, token)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("查询预计到期失败:%d %s", status, body)
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Data expiryHTTPResult `json:"data"`
|
||||||
|
}
|
||||||
|
if err := sonic.Unmarshal(body, &response); err != nil {
|
||||||
|
t.Fatalf("解析预计到期响应失败:%v", err)
|
||||||
|
}
|
||||||
|
assertExpiryJSONNullable(t, body, response.Data.Status == constants.PackageExpiryEstimateStatusExact)
|
||||||
|
return response.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExpiryHTTPUsage(assetID uint, assetType string, status int, expiresAt *time.Time, waiting bool, now time.Time) *model.PackageUsage {
|
||||||
|
usage := &model.PackageUsage{OrderID: assetID, OrderNo: fmt.Sprintf("UR46-STATUS-%s-%d", assetType, assetID), PackageID: assetID, UsageType: assetType, DataLimitMB: 1, Status: status, Priority: 1, ActivatedAt: &now, ExpiresAt: expiresAt, PendingRealnameActivation: waiting, ExpiryBaseSnapshot: constants.PackageExpiryBaseFromActivation, CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, DurationDaysSnapshot: 30}
|
||||||
|
if assetType == constants.AssetTypeDevice {
|
||||||
|
usage.DeviceID = assetID
|
||||||
|
} else {
|
||||||
|
usage.IotCardID = assetID
|
||||||
|
}
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func createExpiryHTTPUsage(db *gorm.DB, usage *model.PackageUsage) error {
|
||||||
|
desiredStatus := usage.Status
|
||||||
|
pendingRealname := usage.PendingRealnameActivation
|
||||||
|
if err := db.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
usage.Status = desiredStatus
|
||||||
|
usage.PendingRealnameActivation = pendingRealname
|
||||||
|
return db.Model(usage).Updates(map[string]any{"status": desiredStatus, "pending_realname_activation": pendingRealname}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertExpiryHTTPStatus 验证详情状态、中文名称、临期标记及 nullable 语义。
|
||||||
|
func assertExpiryHTTPStatus(t *testing.T, status int, body []byte, expected string, exact bool) {
|
||||||
|
t.Helper()
|
||||||
|
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.Status != expected || response.Data.Name == "" {
|
||||||
|
t.Fatalf("预计到期状态错误:want=%s body=%s", expected, body)
|
||||||
|
}
|
||||||
|
if exact != (response.Data.ExpiresAt != nil && response.Data.Days != nil) {
|
||||||
|
t.Fatalf("预计到期 nullable 语义错误:body=%s", body)
|
||||||
|
}
|
||||||
|
if response.Data.Expiring != exact {
|
||||||
|
t.Fatalf("预计到期临期标记错误:exact=%v body=%s", exact, body)
|
||||||
|
}
|
||||||
|
assertExpiryJSONNullable(t, body, exact)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertExpiryJSONNullable 验证日期和天数字段必须存在,并按状态显式返回值或 null。
|
||||||
|
func assertExpiryJSONNullable(t *testing.T, body []byte, exact bool) {
|
||||||
|
t.Helper()
|
||||||
|
var payload struct {
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := sonic.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("解析预计到期原始字段失败:%v", err)
|
||||||
|
}
|
||||||
|
expiresAt, expiresAtExists := payload.Data["estimated_final_expires_at"]
|
||||||
|
days, daysExists := payload.Data["days_until_final_expiry"]
|
||||||
|
if !expiresAtExists || !daysExists || exact != (expiresAt != nil && days != nil) {
|
||||||
|
t.Fatalf("预计到期字段必须存在并显式返回值或 null:exact=%v body=%s", exact, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPackageUsagePersisted(t *testing.T, db *gorm.DB, usageID uint, expectedStatus int) {
|
||||||
|
t.Helper()
|
||||||
|
var usage model.PackageUsage
|
||||||
|
if err := db.First(&usage, usageID).Error; err != nil || usage.Status != expectedStatus {
|
||||||
|
t.Fatalf("套餐使用记录数据库状态错误:id=%d status=%d err=%v", usageID, usage.Status, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPackageUsageRefunded(t *testing.T, db *gorm.DB, usageID, expectedRefundID uint) {
|
||||||
|
t.Helper()
|
||||||
|
var usage model.PackageUsage
|
||||||
|
if err := db.First(&usage, usageID).Error; err != nil || usage.RefundID == nil || *usage.RefundID != expectedRefundID {
|
||||||
|
t.Fatalf("套餐退款数据库状态错误:id=%d refund_id=%v err=%v", usageID, usage.RefundID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClientAssetInfoHTTPReturnsAllPackageExpiryStatuses 验证 C 端卡、设备四状态与后台口径一致。
|
||||||
|
func TestClientAssetInfoHTTPReturnsAllPackageExpiryStatuses(t *testing.T) {
|
||||||
|
env := newAssetTraceHTTPEnv(t)
|
||||||
|
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
|
assets := append(createClientExpiryStatusAssets(t, env, constants.AssetTypeIotCard, 70, now), createClientExpiryStatusAssets(t, env, constants.AssetTypeDevice, 80, now)...)
|
||||||
|
adminToken := env.newAgentToken(t, 0)
|
||||||
|
for _, asset := range assets {
|
||||||
|
client := requestClientExpiryEstimate(t, env, asset.assetType, asset.assetID, asset.virtualNo, asset.identifier)
|
||||||
|
admin := requestExpiryEstimate(t, env, asset.identifier, adminToken)
|
||||||
|
if client.Status != asset.status || client.Status != admin.Status || client.Name == "" || client.Expiring != admin.Expiring || !timePointersEqualRoute(client.ExpiresAt, admin.ExpiresAt) || !intPointersEqualRoute(client.Days, admin.Days) {
|
||||||
|
t.Fatalf("C 端与后台预计到期不一致:asset=%s client=%+v admin=%+v", asset.identifier, client, admin)
|
||||||
|
}
|
||||||
|
exact := asset.status == constants.PackageExpiryEstimateStatusExact
|
||||||
|
if exact != (client.ExpiresAt != nil && client.Days != nil) || exact != client.Expiring {
|
||||||
|
t.Fatalf("C 端 nullable 或临期标记错误:asset=%s result=%+v", asset.identifier, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type clientExpiryTestAsset struct {
|
||||||
|
assetType string
|
||||||
|
assetID uint
|
||||||
|
virtualNo string
|
||||||
|
identifier string
|
||||||
|
status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// createClientExpiryStatusAssets 为指定资产类型创建预计到期四状态夹具。
|
||||||
|
func createClientExpiryStatusAssets(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int, now time.Time) []clientExpiryTestAsset {
|
||||||
|
t.Helper()
|
||||||
|
statuses := []string{constants.PackageExpiryEstimateStatusExact, constants.PackageExpiryEstimateStatusNone, constants.PackageExpiryEstimateStatusWaitingActivation, constants.PackageExpiryEstimateStatusInvalidData}
|
||||||
|
assets := make([]clientExpiryTestAsset, 0, len(statuses))
|
||||||
|
for index, estimateStatus := range statuses {
|
||||||
|
asset := createClientExpiryTestAsset(t, env, assetType, suffix+index)
|
||||||
|
asset.status = estimateStatus
|
||||||
|
if estimateStatus != constants.PackageExpiryEstimateStatusNone {
|
||||||
|
usageStatus, waiting := constants.PackageUsageStatusActive, false
|
||||||
|
var expiresAt *time.Time
|
||||||
|
if estimateStatus == constants.PackageExpiryEstimateStatusExact {
|
||||||
|
value := now.AddDate(0, 0, 5)
|
||||||
|
expiresAt = &value
|
||||||
|
} else if estimateStatus == constants.PackageExpiryEstimateStatusWaitingActivation {
|
||||||
|
usageStatus, waiting = constants.PackageUsageStatusPending, true
|
||||||
|
}
|
||||||
|
usage := newExpiryHTTPUsage(asset.assetID, assetType, usageStatus, expiresAt, waiting, now)
|
||||||
|
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
|
||||||
|
t.Fatalf("创建 C 端状态套餐失败:%v", err)
|
||||||
|
}
|
||||||
|
assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
|
||||||
|
}
|
||||||
|
assets = append(assets, asset)
|
||||||
|
}
|
||||||
|
return assets
|
||||||
|
}
|
||||||
|
|
||||||
|
func createClientExpiryTestAsset(t *testing.T, env *assetTraceHTTPEnv, assetType string, suffix int) clientExpiryTestAsset {
|
||||||
|
t.Helper()
|
||||||
|
if assetType == constants.AssetTypeDevice {
|
||||||
|
device := env.createDevice(t, suffix, nil)
|
||||||
|
return clientExpiryTestAsset{assetType: assetType, assetID: device.ID, virtualNo: device.VirtualNo, identifier: device.VirtualNo}
|
||||||
|
}
|
||||||
|
card := env.createCard(t, suffix, nil)
|
||||||
|
return clientExpiryTestAsset{assetType: assetType, assetID: card.ID, virtualNo: card.VirtualNo, identifier: card.ICCID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClientAssetInfoHTTPReflectsQueueChangesImmediately 验证 C 端实时反映套餐新增、退款和失效。
|
||||||
|
func TestClientAssetInfoHTTPReflectsQueueChangesImmediately(t *testing.T) {
|
||||||
|
env := newAssetTraceHTTPEnv(t)
|
||||||
|
for index, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
|
||||||
|
t.Run(assetType, func(t *testing.T) {
|
||||||
|
asset := createClientExpiryTestAsset(t, env, assetType, 90+index)
|
||||||
|
verifyClientQueueChanges(t, env, asset)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyClientQueueChanges 验证 C 端详情实时反映指定资产的套餐队列变化。
|
||||||
|
func verifyClientQueueChanges(t *testing.T, env *assetTraceHTTPEnv, asset clientExpiryTestAsset) {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||||
|
currentExpiry := now.AddDate(0, 0, 5)
|
||||||
|
current := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusActive, ¤tExpiry, false, now)
|
||||||
|
if err := createExpiryHTTPUsage(env.db, current); err != nil {
|
||||||
|
t.Fatalf("创建 C 端当前套餐失败:%v", err)
|
||||||
|
}
|
||||||
|
assertPackageUsagePersisted(t, env.db, current.ID, constants.PackageUsageStatusActive)
|
||||||
|
request := func() expiryHTTPResult {
|
||||||
|
return requestClientExpiryEstimate(t, env, asset.assetType, asset.assetID, asset.virtualNo, asset.identifier)
|
||||||
|
}
|
||||||
|
initial := request()
|
||||||
|
queued := newExpiryHTTPUsage(asset.assetID, asset.assetType, constants.PackageUsageStatusPending, nil, false, now)
|
||||||
|
queued.PackageID = asset.assetID + 1
|
||||||
|
queued.OrderNo = fmt.Sprintf("UR46-CQ-%s-%d", asset.assetType, asset.assetID)
|
||||||
|
queued.Priority = 2
|
||||||
|
if err := createExpiryHTTPUsage(env.db, queued); err != nil {
|
||||||
|
t.Fatalf("创建 C 端排队套餐失败:%v", err)
|
||||||
|
}
|
||||||
|
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusPending)
|
||||||
|
extended := request()
|
||||||
|
if initial.ExpiresAt == nil || extended.ExpiresAt == nil || !extended.ExpiresAt.After(*initial.ExpiresAt) {
|
||||||
|
t.Fatalf("C 端新增套餐后预计到期未延长:initial=%v extended=%v", initial.ExpiresAt, extended.ExpiresAt)
|
||||||
|
}
|
||||||
|
refundID := uint(990002)
|
||||||
|
if err := env.db.Model(queued).Update("refund_id", refundID).Error; err != nil {
|
||||||
|
t.Fatalf("设置 C 端排队套餐退款失败:%v", err)
|
||||||
|
}
|
||||||
|
assertPackageUsageRefunded(t, env.db, queued.ID, refundID)
|
||||||
|
refunded := request()
|
||||||
|
if refunded.ExpiresAt == nil || !refunded.ExpiresAt.Equal(*initial.ExpiresAt) {
|
||||||
|
t.Fatalf("C 端退款后预计到期未实时恢复:initial=%v refunded=%v", initial.ExpiresAt, refunded.ExpiresAt)
|
||||||
|
}
|
||||||
|
if err := env.db.Model(queued).Updates(map[string]any{"refund_id": nil, "status": constants.PackageUsageStatusInvalidated}).Error; err != nil {
|
||||||
|
t.Fatalf("设置 C 端排队套餐失效失败:%v", err)
|
||||||
|
}
|
||||||
|
assertPackageUsagePersisted(t, env.db, queued.ID, constants.PackageUsageStatusInvalidated)
|
||||||
|
invalidated := request()
|
||||||
|
if invalidated.ExpiresAt == nil || !invalidated.ExpiresAt.Equal(*initial.ExpiresAt) {
|
||||||
|
t.Fatalf("C 端失效后预计到期未实时恢复:initial=%v invalidated=%v", initial.ExpiresAt, invalidated.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestClientExpiryEstimate 创建真实客户认证和绑定后请求 C 端资产信息。
|
||||||
|
func requestClientExpiryEstimate(t *testing.T, env *assetTraceHTTPEnv, assetType string, assetID uint, virtualNo, identifier string) expiryHTTPResult {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now()
|
||||||
|
customer := &model.PersonalCustomer{WxOpenID: fmt.Sprintf("ur46-client-%d-%d", assetID, time.Now().UnixNano()), WxUnionID: fmt.Sprintf("ur46-union-%d", assetID), Status: constants.StatusEnabled}
|
||||||
if err := env.db.Create(customer).Error; err != nil {
|
if err := env.db.Create(customer).Error; err != nil {
|
||||||
t.Fatalf("创建 C 端客户失败:%v", err)
|
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 {
|
binding := &model.PersonalCustomerDevice{CustomerID: customer.ID, VirtualNo: virtualNo, BindAt: now, Status: constants.StatusEnabled}
|
||||||
|
if err := env.db.Create(binding).Error; err != nil {
|
||||||
t.Fatalf("创建 C 端资产绑定失败:%v", err)
|
t.Fatalf("创建 C 端资产绑定失败:%v", err)
|
||||||
}
|
}
|
||||||
jwtManager := auth.NewJWTManager("ur46-client-test-secret", time.Hour)
|
jwtManager := auth.NewJWTManager("ur46-client-status-secret", time.Hour)
|
||||||
token, err := jwtManager.GeneratePersonalCustomerToken(customer.ID, "13800000000", constants.AssetTypeIotCard, card.ID)
|
token, err := jwtManager.GeneratePersonalCustomerToken(customer.ID, "13800000000", assetType, assetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("生成 C 端 JWT 失败:%v", err)
|
t.Fatalf("生成 C 端 JWT 失败:%v", err)
|
||||||
}
|
}
|
||||||
if err := env.redis.Set(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID), token, time.Hour).Err(); err != nil {
|
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.Fatalf("写入 C 端 Token 失败:%v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
_ = env.redis.Del(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID)).Err()
|
_ = env.redis.Del(context.Background(), constants.RedisPersonalCustomerTokenKey(customer.ID)).Err()
|
||||||
})
|
})
|
||||||
cardStore := postgres.NewIotCardStore(env.db, env.redis)
|
cardStore := postgres.NewIotCardStore(env.db, env.redis)
|
||||||
deviceStore := postgres.NewDeviceStore(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())
|
handler := 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())})
|
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", internalMiddleware.NewPersonalAuthMiddleware(jwtManager, env.redis, zap.NewNop()).Authenticate(), handler.GetAssetInfo)
|
||||||
app.Get("/api/c/v1/asset/info", personalAuth.Authenticate(), clientHandler.GetAssetInfo)
|
request, err := http.NewRequest(http.MethodGet, "/api/c/v1/asset/info?identifier="+identifier, nil)
|
||||||
request, err := http.NewRequest(http.MethodGet, "/api/c/v1/asset/info?identifier="+card.ICCID, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("创建 C 端请求失败:%v", err)
|
t.Fatalf("创建 C 端请求失败:%v", err)
|
||||||
}
|
}
|
||||||
@@ -194,22 +529,121 @@ func TestClientAssetInfoHTTPReturnsPackageExpiryEstimate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
body, _ := io.ReadAll(response.Body)
|
body, _ := io.ReadAll(response.Body)
|
||||||
if response.StatusCode != http.StatusOK || !bytes.Contains(body, []byte(`"expiry_estimate_status":"exact"`)) {
|
if response.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("C 端预计到期响应错误:%d %s", response.StatusCode, body)
|
t.Fatalf("C 端预计到期响应失败:%d %s", response.StatusCode, body)
|
||||||
}
|
}
|
||||||
|
var payload struct {
|
||||||
|
Data expiryHTTPResult `json:"data"`
|
||||||
|
}
|
||||||
|
if err := sonic.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("解析 C 端预计到期响应失败:%v", err)
|
||||||
|
}
|
||||||
|
assertExpiryJSONNullable(t, body, payload.Data.Status == constants.PackageExpiryEstimateStatusExact)
|
||||||
|
return payload.Data
|
||||||
|
}
|
||||||
|
|
||||||
|
func timePointersEqualRoute(left, right *time.Time) bool {
|
||||||
|
return left == nil && right == nil || left != nil && right != nil && left.Equal(*right)
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPointersEqualRoute(left, right *int) bool {
|
||||||
|
return left == nil && right == nil || left != nil && right != nil && *left == *right
|
||||||
|
}
|
||||||
|
|
||||||
|
type routeSQLCounterLogger struct {
|
||||||
|
logger.Interface
|
||||||
|
queries []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *routeSQLCounterLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||||
|
sql, rows := fc()
|
||||||
|
l.queries = append(l.queries, sql)
|
||||||
|
l.Interface.Trace(ctx, begin, func() (string, int64) { return sql, rows }, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *routeSQLCounterLogger) Reset() {
|
||||||
|
l.queries = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *routeSQLCounterLogger) Total() int {
|
||||||
|
return len(l.queries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *routeSQLCounterLogger) CountContaining(fragment string) int {
|
||||||
|
count := 0
|
||||||
|
for _, query := range l.queries {
|
||||||
|
if strings.Contains(query, fragment) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAssetListHTTPReturnsPackageExpiryEstimate 验证卡、设备列表在真实 JWT、Redis、Fiber 下批量返回统一预计到期字段。
|
// TestAssetListHTTPReturnsPackageExpiryEstimate 验证卡、设备列表在真实 JWT、Redis、Fiber 下批量返回统一预计到期字段。
|
||||||
func TestAssetListHTTPReturnsPackageExpiryEstimate(t *testing.T) {
|
func TestAssetListHTTPReturnsPackageExpiryEstimate(t *testing.T) {
|
||||||
env := newAssetTraceHTTPEnv(t)
|
env := newAssetTraceHTTPEnv(t)
|
||||||
shopStore := postgres.NewShopStore(env.db, env.redis)
|
counter := &routeSQLCounterLogger{Interface: env.db.Logger}
|
||||||
cardStore := postgres.NewIotCardStore(env.db, env.redis)
|
db := env.db.Session(&gorm.Session{Logger: counter})
|
||||||
deviceStore := postgres.NewDeviceStore(env.db, env.redis)
|
registerPackageExpiryListRoutes(env, db)
|
||||||
seriesStore := postgres.NewPackageSeriesStore(env.db)
|
fixture := createPackageExpiryListFixtures(t, env)
|
||||||
allocationStore := postgres.NewAssetAllocationRecordStore(env.db, env.redis)
|
token := env.newAgentToken(t, 0)
|
||||||
cardSvc := iotCardService.New(env.db, cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(env.db), postgres.NewShopSeriesAllocationStore(env.db), seriesStore, nil, zap.NewNop(), nil)
|
for _, testCase := range []packageExpiryListCase{
|
||||||
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))
|
{path: "/api/admin/iot-cards/standalone", assetType: constants.AssetTypeIotCard, identifier: fixture.lastCardIdentifier, orderedIDs: fixture.cardIDs, packageQueries: 1},
|
||||||
packageExpiry := packageExpiryQuery.NewQuery(env.db)
|
{path: "/api/admin/devices", assetType: constants.AssetTypeDevice, identifier: fixture.lastDeviceIdentifier, orderedIDs: fixture.deviceIDs, packageQueries: 2},
|
||||||
|
} {
|
||||||
|
t.Run(testCase.assetType, func(t *testing.T) {
|
||||||
|
verifyPackageExpiryList(t, env, counter, fixture, testCase, token)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type packageExpiryListFixture struct {
|
||||||
|
batchNo string
|
||||||
|
cardIDs []uint
|
||||||
|
deviceIDs []uint
|
||||||
|
statusIDs map[string]map[string]uint
|
||||||
|
lastCardIdentifier string
|
||||||
|
lastDeviceIdentifier string
|
||||||
|
}
|
||||||
|
|
||||||
|
type packageExpiryListCase struct {
|
||||||
|
path string
|
||||||
|
assetType string
|
||||||
|
identifier string
|
||||||
|
orderedIDs []uint
|
||||||
|
packageQueries int
|
||||||
|
}
|
||||||
|
|
||||||
|
type packageExpiryListItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ExpiresAt *time.Time `json:"estimated_final_expires_at"`
|
||||||
|
Days *int `json:"days_until_final_expiry"`
|
||||||
|
Status string `json:"expiry_estimate_status"`
|
||||||
|
StatusName string `json:"expiry_estimate_status_name"`
|
||||||
|
IsExpiring bool `json:"is_expiring"`
|
||||||
|
fieldsExist bool
|
||||||
|
fieldsNull bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type packageExpiryListPayload struct {
|
||||||
|
Data struct {
|
||||||
|
Items []packageExpiryListItem `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerPackageExpiryListRoutes 使用计数数据库会话注册卡和设备列表路由。
|
||||||
|
func registerPackageExpiryListRoutes(env *assetTraceHTTPEnv, db *gorm.DB) {
|
||||||
|
shopStore := postgres.NewShopStore(db, env.redis)
|
||||||
|
cardStore := postgres.NewIotCardStore(db, nil)
|
||||||
|
deviceStore := postgres.NewDeviceStore(db, nil)
|
||||||
|
seriesStore := postgres.NewPackageSeriesStore(db)
|
||||||
|
allocationStore := postgres.NewAssetAllocationRecordStore(db, env.redis)
|
||||||
|
cardSvc := iotCardService.New(db, cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(db), postgres.NewShopSeriesAllocationStore(db), seriesStore, nil, zap.NewNop(), nil)
|
||||||
|
deviceSvc := deviceService.New(db, nil, deviceStore, postgres.NewDeviceSimBindingStore(db, nil), cardStore, shopStore, allocationStore, postgres.NewShopPackageAllocationStore(db), postgres.NewShopSeriesAllocationStore(db), seriesStore, nil, postgres.NewAssetIdentifierStore(db), nil, postgres.NewEnterpriseDeviceAuthorizationStore(db, nil), postgres.NewEnterpriseStore(db, nil))
|
||||||
|
packageExpiry := packageExpiryQuery.NewQuery(db)
|
||||||
cardSvc.SetPackageExpiryQuery(packageExpiry)
|
cardSvc.SetPackageExpiryQuery(packageExpiry)
|
||||||
deviceSvc.SetPackageExpiryQuery(packageExpiry)
|
deviceSvc.SetPackageExpiryQuery(packageExpiry)
|
||||||
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
|
authMiddleware := pkgMiddleware.Auth(pkgMiddleware.AuthConfig{TokenValidator: func(token string) (*pkgMiddleware.UserContextInfo, error) {
|
||||||
@@ -221,27 +655,133 @@ func TestAssetListHTTPReturnsPackageExpiryEstimate(t *testing.T) {
|
|||||||
}, ShopStore: shopStore})
|
}, ShopStore: shopStore})
|
||||||
env.app.Get("/api/admin/iot-cards/standalone", authMiddleware, admin.NewIotCardHandler(cardSvc).ListStandalone)
|
env.app.Get("/api/admin/iot-cards/standalone", authMiddleware, admin.NewIotCardHandler(cardSvc).ListStandalone)
|
||||||
env.app.Get("/api/admin/devices", authMiddleware, admin.NewDeviceHandler(deviceSvc).List)
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createPackageExpiryListFixtures 创建卡和设备各 100 条的四状态列表数据。
|
||||||
|
func createPackageExpiryListFixtures(t *testing.T, env *assetTraceHTTPEnv) packageExpiryListFixture {
|
||||||
|
t.Helper()
|
||||||
|
fixture := packageExpiryListFixture{batchNo: "UR46-LIST-" + strconv.FormatInt(time.Now().UnixNano(), 10), cardIDs: make([]uint, 0, 100), deviceIDs: make([]uint, 0, 100), statusIDs: map[string]map[string]uint{constants.AssetTypeIotCard: {}, constants.AssetTypeDevice: {}}}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
expiresAt := now.AddDate(0, 0, 5)
|
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}} {
|
for index := 0; index < 100; index++ {
|
||||||
if err := env.db.Create(usage).Error; err != nil {
|
card := env.createCard(t, 1000+index, nil)
|
||||||
|
device := env.createDevice(t, 1000+index, nil)
|
||||||
|
if err := env.db.Model(card).Updates(map[string]any{"is_standalone": true, "batch_no": fixture.batchNo}).Error; err != nil {
|
||||||
|
t.Fatalf("设置卡列表 fixture 失败:%v", err)
|
||||||
|
}
|
||||||
|
if err := env.db.Model(device).Update("batch_no", fixture.batchNo).Error; err != nil {
|
||||||
|
t.Fatalf("设置设备列表 fixture 失败:%v", err)
|
||||||
|
}
|
||||||
|
fixture.cardIDs = append(fixture.cardIDs, card.ID)
|
||||||
|
fixture.deviceIDs = append(fixture.deviceIDs, device.ID)
|
||||||
|
fixture.lastCardIdentifier, fixture.lastDeviceIdentifier = card.ICCID, device.VirtualNo
|
||||||
|
createPackageExpiryListUsage(t, env, constants.AssetTypeIotCard, card.ID, index, now, expiresAt, fixture.statusIDs)
|
||||||
|
createPackageExpiryListUsage(t, env, constants.AssetTypeDevice, device.ID, index, now, expiresAt, fixture.statusIDs)
|
||||||
|
}
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPackageExpiryListUsage 按资产序号分配精确、无套餐、待激活和异常状态。
|
||||||
|
func createPackageExpiryListUsage(t *testing.T, env *assetTraceHTTPEnv, assetType string, assetID uint, index int, now, expiresAt time.Time, statusIDs map[string]map[string]uint) {
|
||||||
|
t.Helper()
|
||||||
|
estimateStatus := constants.PackageExpiryEstimateStatusExact
|
||||||
|
usageStatus := constants.PackageUsageStatusActive
|
||||||
|
usageExpiry := &expiresAt
|
||||||
|
waiting := false
|
||||||
|
switch index {
|
||||||
|
case 1:
|
||||||
|
estimateStatus = constants.PackageExpiryEstimateStatusNone
|
||||||
|
statusIDs[assetType][estimateStatus] = assetID
|
||||||
|
return
|
||||||
|
case 2:
|
||||||
|
estimateStatus = constants.PackageExpiryEstimateStatusWaitingActivation
|
||||||
|
usageStatus, usageExpiry, waiting = constants.PackageUsageStatusPending, nil, true
|
||||||
|
case 3:
|
||||||
|
estimateStatus = constants.PackageExpiryEstimateStatusInvalidData
|
||||||
|
usageExpiry = nil
|
||||||
|
}
|
||||||
|
statusIDs[assetType][estimateStatus] = assetID
|
||||||
|
usage := newExpiryHTTPUsage(assetID, assetType, usageStatus, usageExpiry, waiting, now)
|
||||||
|
if err := createExpiryHTTPUsage(env.db, usage); err != nil {
|
||||||
t.Fatalf("创建列表套餐失败:%v", err)
|
t.Fatalf("创建列表套餐失败:%v", err)
|
||||||
}
|
}
|
||||||
|
assertPackageUsagePersisted(t, env.db, usage.ID, usageStatus)
|
||||||
}
|
}
|
||||||
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"} {
|
// verifyPackageExpiryList 验证列表响应、固定查询数、性能、筛选、排序和详情一致性。
|
||||||
status, body := env.request(t, path, token)
|
func verifyPackageExpiryList(t *testing.T, env *assetTraceHTTPEnv, counter *routeSQLCounterLogger, fixture packageExpiryListFixture, testCase packageExpiryListCase, token string) {
|
||||||
if status != http.StatusOK || !bytes.Contains(body, []byte(`"expiry_estimate_status":"exact"`)) {
|
t.Helper()
|
||||||
t.Fatalf("列表预计到期响应错误:path=%s status=%d body=%s", path, status, body)
|
baseQuery := "?page=1&page_size=100&batch_no=" + fixture.batchNo
|
||||||
|
counter.Reset()
|
||||||
|
oneStatus, oneBody := env.request(t, testCase.path+strings.Replace(baseQuery, "page_size=100", "page_size=1", 1), token)
|
||||||
|
oneAssetQueries := counter.Total()
|
||||||
|
if oneStatus != http.StatusOK {
|
||||||
|
t.Fatalf("单资产分页请求失败:path=%s status=%d body=%s", testCase.path, oneStatus, oneBody)
|
||||||
|
}
|
||||||
|
counter.Reset()
|
||||||
|
startedAt := time.Now()
|
||||||
|
status, body := env.request(t, testCase.path+baseQuery, token)
|
||||||
|
requestDuration := time.Since(startedAt)
|
||||||
|
hundredAssetQueries := counter.Total()
|
||||||
|
if status != http.StatusOK || oneAssetQueries != hundredAssetQueries || counter.CountContaining("tb_package_usage") != testCase.packageQueries || requestDuration >= testHTTPPerformanceLimit {
|
||||||
|
t.Fatalf("列表批量查询或性能错误:path=%s status=%d one=%d hundred=%d package_queries=%d duration=%v", testCase.path, status, oneAssetQueries, hundredAssetQueries, counter.CountContaining("tb_package_usage"), requestDuration)
|
||||||
|
}
|
||||||
|
payload := decodePackageExpiryList(t, body)
|
||||||
|
if payload.Data.Total != testListPageSize || payload.Data.Page != 1 || payload.Data.Size != testListPageSize || len(payload.Data.Items) != testListPageSize || payload.Data.Items[0].ID != testCase.orderedIDs[testListPageSize-1] {
|
||||||
|
t.Fatalf("列表分页或排序错误:path=%s payload=%+v", testCase.path, payload.Data)
|
||||||
|
}
|
||||||
|
assertPackageExpiryListStatuses(t, payload.Data.Items, fixture.statusIDs[testCase.assetType])
|
||||||
|
detail := requestExpiryEstimate(t, env, testCase.identifier, token)
|
||||||
|
first := payload.Data.Items[0]
|
||||||
|
if first.Status != detail.Status || !timePointersEqualRoute(first.ExpiresAt, detail.ExpiresAt) || !intPointersEqualRoute(first.Days, detail.Days) {
|
||||||
|
t.Fatalf("列表与详情预计到期不一致:list=%+v detail=%+v", first, detail)
|
||||||
|
}
|
||||||
|
status, pageTwoBody := env.request(t, testCase.path+"?page=2&page_size=50&batch_no="+fixture.batchNo, token)
|
||||||
|
pageTwo := decodePackageExpiryList(t, pageTwoBody)
|
||||||
|
if status != http.StatusOK || pageTwo.Data.Total != 100 || len(pageTwo.Data.Items) != 50 || pageTwo.Data.Items[0].ID != testCase.orderedIDs[49] {
|
||||||
|
t.Fatalf("列表翻页不稳定:path=%s status=%d payload=%+v", testCase.path, status, pageTwo.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodePackageExpiryList 同时解析类型字段和原始 JSON,以区分字段缺失与显式 null。
|
||||||
|
func decodePackageExpiryList(t *testing.T, body []byte) packageExpiryListPayload {
|
||||||
|
t.Helper()
|
||||||
|
var payload packageExpiryListPayload
|
||||||
|
if err := sonic.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("解析列表分页响应失败:%v", err)
|
||||||
|
}
|
||||||
|
var raw struct {
|
||||||
|
Data struct {
|
||||||
|
Items []map[string]any `json:"items"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := sonic.Unmarshal(body, &raw); err != nil || len(raw.Data.Items) != len(payload.Data.Items) {
|
||||||
|
t.Fatalf("解析列表原始字段失败:%v", err)
|
||||||
|
}
|
||||||
|
for index, item := range raw.Data.Items {
|
||||||
|
expiresAt, expiresAtExists := item["estimated_final_expires_at"]
|
||||||
|
days, daysExists := item["days_until_final_expiry"]
|
||||||
|
payload.Data.Items[index].fieldsExist = expiresAtExists && daysExists
|
||||||
|
payload.Data.Items[index].fieldsNull = expiresAt == nil && days == nil
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertPackageExpiryListStatuses 逐项验证列表四状态、字段存在性和 nullable 语义。
|
||||||
|
func assertPackageExpiryListStatuses(t *testing.T, items []packageExpiryListItem, expectedIDs map[string]uint) {
|
||||||
|
t.Helper()
|
||||||
|
byID := make(map[uint]packageExpiryListItem, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
byID[item.ID] = item
|
||||||
|
}
|
||||||
|
for status, id := range expectedIDs {
|
||||||
|
item, ok := byID[id]
|
||||||
|
if !ok || item.Status != status || item.StatusName == "" {
|
||||||
|
t.Fatalf("列表缺少状态投影:status=%s id=%d item=%+v", status, id, item)
|
||||||
|
}
|
||||||
|
exact := status == constants.PackageExpiryEstimateStatusExact
|
||||||
|
if !item.fieldsExist || exact == item.fieldsNull || exact != (item.ExpiresAt != nil && item.Days != nil) || exact != item.IsExpiring {
|
||||||
|
t.Fatalf("列表 nullable 或临期标记错误:status=%s item=%+v", status, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,6 +896,15 @@ func (e *assetTraceHTTPEnv) createCard(t *testing.T, suffix int, shopID *uint) *
|
|||||||
return card
|
return card
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *assetTraceHTTPEnv) createDevice(t *testing.T, suffix int, shopID *uint) *model.Device {
|
||||||
|
t.Helper()
|
||||||
|
device := &model.Device{VirtualNo: fmt.Sprintf("UR46-HTTP-DEVICE-%04d-%d", suffix, time.Now().UnixNano()), ShopID: shopID, Status: constants.DeviceStatusInStock, AssetStatus: constants.AssetStatusInStock, MaxSimSlots: 4}
|
||||||
|
if err := e.db.Create(device).Error; err != nil {
|
||||||
|
t.Fatalf("创建 UR46 HTTP 测试设备失败:%v", err)
|
||||||
|
}
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
func (e *assetTraceHTTPEnv) createOrder(t *testing.T, exchangeNo string, oldCard, newCard *model.IotCard, oldSnapshot, newSnapshot string, completedAt time.Time) {
|
func (e *assetTraceHTTPEnv) createOrder(t *testing.T, exchangeNo string, oldCard, newCard *model.IotCard, oldSnapshot, newSnapshot string, completedAt time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
newID := newCard.ID
|
newID := newCard.ID
|
||||||
|
|||||||
Reference in New Issue
Block a user