8.4 KiB
需求04:退款中资产禁止换货
需求06/11:资产预计最终到期时间
状态:原需求独立稿;最终口径以标准评审稿为准。
需求04:退款中禁止换货
业务规则
资产存在未结束的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。
拦截范围:
status=1待审批。status=4已退回,等待提交人修改。status=2已通过但processing_status!=2,实际退款仍在处理、等待处理或失败重试。
不拦截:status=3 已拒绝,或 status=2 AND processing_status=2 已完成实际退款。processing_status 由需求20新增。
数据模型
退款模型:RefundRequest(表 tb_refund_request)
资产字段为两个独立字段(无 asset_type/asset_id):
iot_card_id *uint:IoT卡ID(卡类资产)device_id *uint:设备ID(设备类资产)
状态常量(internal/model/refund.go):
RefundStatusPending = 1 // 待审批
RefundStatusApproved = 2 // 已通过
RefundStatusRejected = 3 // 已拒绝
RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货)
实现位置
换货单创建入口:internal/service/exchange/service.go 创建前校验。
后端
Store 新增方法(internal/store/postgres/refund_store.go):
// HasActiveRefundByCard 检查指定IoT卡是否存在未结束的退款申请
func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
Where(`iot_card_id = ?
AND deleted_at IS NULL
AND (
status IN (?, ?)
OR (status = ? AND processing_status <> ?)
)`,
cardID,
model.RefundStatusPending, // 1=待审批
model.RefundStatusReturned, // 4=已退回(拦截)
model.RefundStatusApproved, // 2=审批已通过
constants.ProcessingStatusSucceeded,
).Count(&count).Error
return count > 0, err
}
// HasActiveRefundByDevice 检查指定设备是否存在未结束的退款申请
func (s *RefundStore) HasActiveRefundByDevice(ctx context.Context, deviceID uint) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
Where(`device_id = ?
AND deleted_at IS NULL
AND (
status IN (?, ?)
OR (status = ? AND processing_status <> ?)
)`,
deviceID,
model.RefundStatusPending, // 1=待审批
model.RefundStatusReturned, // 4=已退回(拦截)
model.RefundStatusApproved, // 2=审批已通过
constants.ProcessingStatusSucceeded,
).Count(&count).Error
return count > 0, err
}
Service 校验(internal/service/exchange/service.go 创建换货单前调用):
// validateNoActiveRefund 校验资产是否有未结束的退款申请
func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *resolvedExchangeAsset) error {
var hasActive bool
var err error
if asset.CardID != nil {
hasActive, err = s.refundStore.HasActiveRefundByCard(ctx, *asset.CardID)
} else if asset.DeviceID != nil {
hasActive, err = s.refundStore.HasActiveRefundByDevice(ctx, *asset.DeviceID)
}
if err != nil {
return err
}
if hasActive {
return errors.New(errors.CodeForbidden, "该资产存在退款申请,无法操作换货")
}
return nil
}
前端
无需改动。换货申请时后端返回错误,前端展示错误信息即可。
需求06/11:资产详情-预计最终到期时间
业务规则
需求06与需求11是同一个业务需求的两种描述,不再拆成“当前套餐到期”和“所有套餐最后到期”两个资产汇总字段。资产详情页只展示:当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最终到期时间。
不能只取已经写入 expires_at 的最大值。排队套餐通常尚未激活,expires_at 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。
加油包不延长主套餐服务周期,不参与本字段计算。已失效、已退款或已过期的使用记录不参与。
接口
后台资产详情页实际调用的是:
GET /api/admin/assets/resolve/:identifier
响应 DTO:AssetResolveResponse(internal/model/dto/asset_dto.go)
该 DTO 目前只有当前套餐到期时间,需新增统一的最终到期响应,并由前端替代原资产汇总展示。
后端
DTO 新增字段(internal/model/dto/asset_dto.go → AssetResolveResponse):
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at" description:"当前及排队主套餐接续后的预计最终到期时间"`
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余自然日"`
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态 (exact:可推算, waiting_activation:等待未知激活时间, none:无套餐)"`
Store 新增方法(internal/store/postgres/package_usage_store.go):
// GetProjectableMainPackagesByCardID 获取可推算的当前/排队主套餐。
func (s *PackageUsageStore) GetProjectableMainPackagesByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) {
var usages []*model.PackageUsage
err := s.db.WithContext(ctx).
Where("iot_card_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", cardID,
constants.PackageUsageStatusActive, // 1=生效中
constants.PackageUsageStatusPending, // 0=待生效
constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
).
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error
return usages, err
}
// GetProjectableMainPackagesByDeviceID 获取可推算的当前/排队主套餐。
func (s *PackageUsageStore) GetProjectableMainPackagesByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) {
var usages []*model.PackageUsage
err := s.db.WithContext(ctx).
Where("device_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", deviceID,
constants.PackageUsageStatusActive, // 1=生效中
constants.PackageUsageStatusPending, // 0=待生效
constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
).
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error
return usages, err
}
新建 PackageUsage 必须快照 calendar_type_snapshot、duration_months_snapshot、duration_days_snapshot,与需求05的 expiry_base_snapshot 一起在购买时写入。旧记录没有时长快照时才回退读取当前套餐,且仅作为历史兼容。
Service 计算逻辑(在 ResolveAsset 结果组装处添加):
// 1. 找当前主套餐的 expires_at 作为 cursor。
// 2. 按 priority、created_at、id 遍历排队主套餐。
// 3. 每个排队套餐以 cursor 为预计激活点,使用其购买时长快照计算新的 cursor。
// 4. cursor 即预计最后到期时间。
lastExpiry, err := s.packageUsageStore.ProjectLastMainPackageExpiry(ctx, assetType, assetID, now)
if err != nil {
return nil, err
}
resp.EstimatedFinalExpiresAt = lastExpiry
ProjectLastMainPackageExpiry 是 Query 计算,不写快照表:当前主套餐到期时间变化、排队套餐新增/退款失效后,下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件(例如尚未实名),返回 null,前端显示“—”,不伪造日期。
前端
资产详情“套餐信息”板块只保留一个资产汇总展示:
预计套餐到期时间:2027-01-01
读取 estimated_final_expires_at。exact 时展示日期;waiting_activation 时展示“待激活后起算”;none 时展示“—”。当前套餐自身的 expires_at 仅在套餐明细列表展示,不再作为第二个资产汇总字段。
高亮和临期提醒统一使用 days_until_final_expiry,前端不得再根据 current_package_expires_at 自行计算另一套剩余天数。