# 需求04:退款中资产禁止换货 # 需求06:资产最后到期时间 > 状态:待评审 --- ## 需求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`): ```go RefundStatusPending = 1 // 待审批 RefundStatusApproved = 2 // 已通过 RefundStatusRejected = 3 // 已拒绝 RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货) ``` ### 实现位置 换货单创建入口:`internal/service/exchange/service.go` 创建前校验。 ### 后端 **Store 新增方法**(`internal/store/postgres/refund_store.go`): ```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` 创建换货单前调用): ```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:资产详情-所有套餐的最后到期时间 ### 业务规则 资产详情页展示:**当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最后到期时间**。 不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活,`expires_at` 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。 加油包不延长主套餐服务周期,不参与本字段计算。已失效、已退款或已过期的使用记录不参与。 ### 接口 后台资产详情页实际调用的是: ``` GET /api/admin/assets/resolve/:identifier ``` 响应 DTO:`AssetResolveResponse`(`internal/model/dto/asset_dto.go`) 该 DTO 目前**不含** `last_package_expires_at` 字段,需新增。 ### 后端 **DTO 新增字段**(`internal/model/dto/asset_dto.go` → `AssetResolveResponse`): ```go // 当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为 null LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"当前主套餐及排队主套餐接续后的预计最后到期时间,无可推算套餐时为null"` ``` **Store 新增方法**(`internal/store/postgres/package_usage_store.go`): ```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` 结果组装处添加): ```go // 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.LastPackageExpiresAt = lastExpiry ``` `ProjectLastMainPackageExpiry` 是 Query 计算,不写快照表:当前主套餐到期时间变化、排队套餐新增/退款失效后,下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件(例如尚未实名),返回 `null`,前端显示“—”,不伪造日期。 ### 前端 资产详情"套餐信息"板块新增展示: ``` 预计最后到期时间:2027-01-01 ``` 读取 `resolve` 接口返回的 `last_package_expires_at`,有值则展示,无值(无套餐)展示"—"。